@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509
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/dist/chat-protocol/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +216 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +213 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +506 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +490 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
- package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
- package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
- package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
- package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
- package/dist/chunk-3QSR22IC.js.map +1 -0
- package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
- package/dist/chunk-4G5TLHJD.cjs.map +1 -0
- package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
- package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
- package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
- package/dist/chunk-AEELJC4N.js.map +1 -0
- package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
- package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
- package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
- package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
- package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
- package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
- package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
- package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
- package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
- package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
- package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
- package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
- package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
- package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
- package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
- package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
- package/dist/chunk-LBAKLT6T.cjs.map +1 -0
- package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
- package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
- package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
- package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
- package/dist/chunk-PO5O654H.js.map +1 -0
- package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
- package/dist/chunk-QJJISLPA.cjs.map +1 -0
- package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
- package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
- package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
- package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
- package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
- package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
- package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
- package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
- package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +21 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -93
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +174 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +44 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +55 -13
- package/dist/components/ui/markdown/base-components.d.ts +51 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +60 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +71 -13
- package/dist/utils/index.cjs +169 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
- package/src/chat-protocol/decode.ts +278 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +254 -0
- package/src/chat-protocol/frames.ts +245 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +264 -0
- package/src/components/chat/.chat-message-list.md +10 -6
- package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
- package/src/components/chat/chat-message-enhanced.tsx +122 -20
- package/src/components/chat/chat-message-list.tsx +515 -133
- package/src/components/chat/embeddable-chat.tsx +6 -0
- package/src/components/chat/hooks/.index.md +30 -33
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
- package/src/components/chat/index.ts +5 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
- package/src/components/chat/stream/delta-batcher.ts +168 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +340 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +39 -49
- package/src/components/chat/types/.processing.types.md +29 -58
- package/src/components/chat/types/.unified-chat-state.types.md +2 -2
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -53
- package/src/components/chat/types/unified-chat-state.types.ts +16 -0
- package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
- package/src/components/chat/utils/.history-merge.md +44 -3
- package/src/components/chat/utils/.index.md +31 -40
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
- package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
- package/src/components/chat/utils/history-merge.ts +85 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +361 -385
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
- package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
- package/src/components/ui/markdown/base-components.tsx +360 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3584 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +390 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-OCEKO5CW.js.map +0 -1
- package/dist/chunk-QTYZMP6D.cjs.map +0 -1
- package/dist/chunk-TLMJHMXJ.js.map +0 -1
- package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
- package/dist/chunk-WA7RR64F.js.map +0 -1
- package/dist/chunk-WK4N5VBX.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
- package/src/components/chat/utils/.chunk-parser.md +0 -62
- package/src/components/chat/utils/chunk-parser.ts +0 -262
- package/src/components/ui/.simple-markdown-renderer.md +0 -52
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -964
- /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
- /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
- /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
- /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
- /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
- /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
- /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
- /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
- /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
- /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
- /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-3 tests — `createChatDialogStore` cross-side projection.
|
|
3
|
+
*
|
|
4
|
+
* Exactly two operations fan out across sides of the same dialogId:
|
|
5
|
+
* approval-resolution by requestId and tool-execution merge by execId.
|
|
6
|
+
* The projection is recomputed from state (never re-injected into the other
|
|
7
|
+
* side's seq stream), so per-side seq gates stay untouched and replays are
|
|
8
|
+
* idempotent with full referential stability on untouched messages.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
createChatDialogStore,
|
|
14
|
+
DEFAULT_MAX_REDUCERS,
|
|
15
|
+
MAX_PENDING_RETAIN_KEYS,
|
|
16
|
+
} from '../chat-dialog-store'
|
|
17
|
+
import type { EvictedReducerState } from '../chat-dialog-store'
|
|
18
|
+
import type { ChatStreamEvent } from '../../../../chat-protocol/events'
|
|
19
|
+
|
|
20
|
+
/** ES2020-compatible `arr.at(-1)`. The package pins `lib: ES2020`
|
|
21
|
+
* (target ES2020 is the shipped contract), where `Array.prototype.at`
|
|
22
|
+
* is not declared — the source tree uses `xs[xs.length - 1]` for the
|
|
23
|
+
* same reason. */
|
|
24
|
+
const last = <T,>(xs: readonly T[] | undefined): T | undefined =>
|
|
25
|
+
xs === undefined ? undefined : xs[xs.length - 1]
|
|
26
|
+
|
|
27
|
+
const DIALOG = 'dlg-1'
|
|
28
|
+
|
|
29
|
+
const batchApproval = (seq?: number): ChatStreamEvent => ({
|
|
30
|
+
type: 'approval-request',
|
|
31
|
+
requestId: 'req-1',
|
|
32
|
+
approvalType: 'CLIENT',
|
|
33
|
+
toolCalls: [
|
|
34
|
+
{
|
|
35
|
+
toolExecutionRequestId: 'exec-1',
|
|
36
|
+
toolName: 'run_command',
|
|
37
|
+
requiresApproval: true,
|
|
38
|
+
approvalType: 'CLIENT',
|
|
39
|
+
toolCallArguments: null,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
...(seq != null ? { seq } : {}),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function seedBothSides(store: ReturnType<typeof createChatDialogStore>) {
|
|
46
|
+
// Side A (client view) and side B (technician mirror) both saw the turn
|
|
47
|
+
// with the approval card — each through its OWN seq stream.
|
|
48
|
+
for (const side of ['main', 'observer'] as const) {
|
|
49
|
+
store.apply(DIALOG, side, { type: 'turn-start', seq: 1 })
|
|
50
|
+
store.apply(DIALOG, side, { type: 'text-delta', text: 'needs approval', seq: 2 })
|
|
51
|
+
store.apply(DIALOG, side, batchApproval(3))
|
|
52
|
+
store.apply(DIALOG, side, { type: 'turn-end', seq: 4 })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('createChatDialogStore — cross-side projection', () => {
|
|
57
|
+
it('(a) a same-id TEXT event on one side does NOT touch the other side', () => {
|
|
58
|
+
const store = createChatDialogStore()
|
|
59
|
+
seedBothSides(store)
|
|
60
|
+
const bBefore = store.getSnapshot(DIALOG, 'observer')
|
|
61
|
+
|
|
62
|
+
store.apply(DIALOG, 'main', { type: 'text-delta', text: 'main-only continuation', seq: 5 })
|
|
63
|
+
|
|
64
|
+
expect(store.getSnapshot(DIALOG, 'observer')).toBe(bBefore)
|
|
65
|
+
const aTrailing = last(store.getSnapshot(DIALOG, 'main').messages)
|
|
66
|
+
const bTrailing = last(store.getSnapshot(DIALOG, 'observer').messages)
|
|
67
|
+
expect(JSON.stringify(aTrailing)).not.toEqual(JSON.stringify(bTrailing))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('(b) approval resolved on side A updates side B matching card (status + resolver)', () => {
|
|
71
|
+
const store = createChatDialogStore()
|
|
72
|
+
seedBothSides(store)
|
|
73
|
+
|
|
74
|
+
store.apply(DIALOG, 'main', {
|
|
75
|
+
type: 'approval-resolved',
|
|
76
|
+
requestId: 'req-1',
|
|
77
|
+
status: 'approved',
|
|
78
|
+
approvalType: 'CLIENT',
|
|
79
|
+
resolvedByName: 'Alice Client',
|
|
80
|
+
seq: 5,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
for (const side of ['main', 'observer'] as const) {
|
|
84
|
+
const messages = store.getSnapshot(DIALOG, side).messages
|
|
85
|
+
const card = messages
|
|
86
|
+
.flatMap((m) => m.segments ?? [])
|
|
87
|
+
.find((s) => s.type === 'approval_batch')
|
|
88
|
+
expect(card).toBeDefined()
|
|
89
|
+
expect((card as { status?: string }).status).toBe('approved')
|
|
90
|
+
expect((card as { resolvedByName?: string | null }).resolvedByName).toBe('Alice Client')
|
|
91
|
+
// Status map mirrored on both sides so future replays render resolved.
|
|
92
|
+
expect(store.getSnapshot(DIALOG, side).approvalStatuses['req-1']).toBe('approved')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('(b2) tool execution on side A merges into side B batch card WITHOUT appending', () => {
|
|
97
|
+
const store = createChatDialogStore()
|
|
98
|
+
seedBothSides(store)
|
|
99
|
+
store.apply(DIALOG, 'main', {
|
|
100
|
+
type: 'approval-resolved',
|
|
101
|
+
requestId: 'req-1',
|
|
102
|
+
status: 'approved',
|
|
103
|
+
seq: 5,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
store.apply(DIALOG, 'main', {
|
|
107
|
+
type: 'tool-execution',
|
|
108
|
+
data: {
|
|
109
|
+
type: 'EXECUTED_TOOL',
|
|
110
|
+
integratedToolType: 'SHELL',
|
|
111
|
+
toolFunction: 'run_command',
|
|
112
|
+
result: 'ok',
|
|
113
|
+
success: true,
|
|
114
|
+
toolExecutionRequestId: 'exec-1',
|
|
115
|
+
},
|
|
116
|
+
seq: 6,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const b = store.getSnapshot(DIALOG, 'observer')
|
|
120
|
+
const bSegments = b.messages.flatMap((m) => m.segments ?? [])
|
|
121
|
+
const batch = bSegments.find((s) => s.type === 'approval_batch')
|
|
122
|
+
expect(
|
|
123
|
+
(batch as { data: { executions?: Record<string, unknown> } }).data.executions,
|
|
124
|
+
).toEqual({ 'exec-1': { status: 'done', result: 'ok', success: true } })
|
|
125
|
+
// Merge-only projection: no standalone tool card appeared on side B.
|
|
126
|
+
expect(bSegments.filter((s) => s.type === 'tool_execution')).toHaveLength(0)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('(c) replaying side A events after the projection: no double-application + Object.is stability on side B untouched messages', () => {
|
|
130
|
+
const store = createChatDialogStore()
|
|
131
|
+
seedBothSides(store)
|
|
132
|
+
|
|
133
|
+
const resolve: ChatStreamEvent = {
|
|
134
|
+
type: 'approval-resolved',
|
|
135
|
+
requestId: 'req-1',
|
|
136
|
+
status: 'approved',
|
|
137
|
+
approvalType: 'CLIENT',
|
|
138
|
+
resolvedByName: 'Alice Client',
|
|
139
|
+
seq: 5,
|
|
140
|
+
}
|
|
141
|
+
const executedEvent: ChatStreamEvent = {
|
|
142
|
+
type: 'tool-execution',
|
|
143
|
+
data: {
|
|
144
|
+
type: 'EXECUTED_TOOL',
|
|
145
|
+
integratedToolType: 'SHELL',
|
|
146
|
+
toolFunction: 'run_command',
|
|
147
|
+
result: 'ok',
|
|
148
|
+
success: true,
|
|
149
|
+
toolExecutionRequestId: 'exec-1',
|
|
150
|
+
},
|
|
151
|
+
seq: 6,
|
|
152
|
+
}
|
|
153
|
+
store.apply(DIALOG, 'main', resolve)
|
|
154
|
+
store.apply(DIALOG, 'main', executedEvent)
|
|
155
|
+
|
|
156
|
+
const aState = store.getSnapshot(DIALOG, 'main')
|
|
157
|
+
const bState = store.getSnapshot(DIALOG, 'observer')
|
|
158
|
+
const bMessages = bState.messages
|
|
159
|
+
|
|
160
|
+
// Replay side A's events (reconnect back-fill). Side A drops them via
|
|
161
|
+
// its seq gate; the recomputed projection over side B is a value-level
|
|
162
|
+
// no-op → identical references end-to-end.
|
|
163
|
+
store.apply(DIALOG, 'main', resolve)
|
|
164
|
+
store.apply(DIALOG, 'main', executedEvent)
|
|
165
|
+
|
|
166
|
+
expect(store.getSnapshot(DIALOG, 'main')).toBe(aState)
|
|
167
|
+
const bAfter = store.getSnapshot(DIALOG, 'observer')
|
|
168
|
+
expect(bAfter.messages).toBe(bMessages)
|
|
169
|
+
bAfter.messages.forEach((m, i) => expect(m).toBe(bMessages[i]))
|
|
170
|
+
expect(bAfter).toBe(bState)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("(c2) the projection never advances the other side's seq gate", () => {
|
|
174
|
+
const store = createChatDialogStore()
|
|
175
|
+
seedBothSides(store)
|
|
176
|
+
|
|
177
|
+
// Side A resolves at a HIGH seq…
|
|
178
|
+
store.apply(DIALOG, 'main', {
|
|
179
|
+
type: 'approval-resolved',
|
|
180
|
+
requestId: 'req-1',
|
|
181
|
+
status: 'approved',
|
|
182
|
+
seq: 100,
|
|
183
|
+
})
|
|
184
|
+
// …side B's own stream continues at ITS low seq — must still apply.
|
|
185
|
+
store.apply(DIALOG, 'observer', { type: 'text-delta', text: ' b-continues', seq: 5 })
|
|
186
|
+
const bTrailing = last(store.getSnapshot(DIALOG, 'observer').messages)
|
|
187
|
+
const texts = (bTrailing?.segments ?? []).filter((s) => s.type === 'text') as Array<{
|
|
188
|
+
text: string
|
|
189
|
+
}>
|
|
190
|
+
expect(last(texts)?.text).toContain('b-continues')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The projection reaches the sides that EXIST when the event lands — it is a
|
|
195
|
+
* fan-out over the live reducer map, not a durable log. A side materialized
|
|
196
|
+
* later (panel opened after the fact, or its key LRU-evicted and recreated)
|
|
197
|
+
* therefore starts from history + the parked status map, NOT from replayed
|
|
198
|
+
* projections.
|
|
199
|
+
*
|
|
200
|
+
* Untested until now, and the tempting "fix" — having `otherSides` create the
|
|
201
|
+
* missing sides — is wrong twice over: it would materialize reducers for
|
|
202
|
+
* panels nobody is rendering (defeating the LRU cap that exists to bound
|
|
203
|
+
* exactly that), and it would seed them with a card they never received the
|
|
204
|
+
* APPROVAL_REQUEST for. This fixture states the host contract so that
|
|
205
|
+
* refactor fails loudly instead of silently changing the memory profile.
|
|
206
|
+
*/
|
|
207
|
+
it('(d) a side materialized AFTER the projection does not receive it retroactively', () => {
|
|
208
|
+
const store = createChatDialogStore()
|
|
209
|
+
// ONLY side A exists when the resolution arrives.
|
|
210
|
+
store.apply(DIALOG, 'main', { type: 'turn-start', seq: 1 })
|
|
211
|
+
store.apply(DIALOG, 'main', batchApproval(2))
|
|
212
|
+
store.apply(DIALOG, 'main', {
|
|
213
|
+
type: 'approval-resolved',
|
|
214
|
+
requestId: 'req-1',
|
|
215
|
+
status: 'approved',
|
|
216
|
+
seq: 3,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// The projection created NO reducer for the absent side.
|
|
220
|
+
expect(store.getSnapshot(DIALOG, 'observer').messages).toHaveLength(0)
|
|
221
|
+
expect(store.getSnapshot(DIALOG, 'observer').approvalStatuses).toEqual({})
|
|
222
|
+
|
|
223
|
+
// Side B opens now and replays its own history: it must learn the
|
|
224
|
+
// resolution from the host (parked / persisted statuses), not from the
|
|
225
|
+
// projection that already fired.
|
|
226
|
+
store.apply(DIALOG, 'observer', { type: 'turn-start', seq: 1 })
|
|
227
|
+
store.apply(DIALOG, 'observer', batchApproval(2))
|
|
228
|
+
expect(store.getSnapshot(DIALOG, 'observer').approvalStatuses).toEqual({})
|
|
229
|
+
|
|
230
|
+
store.mutate(DIALOG, 'observer', (r) => r.mergeApprovalStatuses({ 'req-1': 'approved' }))
|
|
231
|
+
expect(store.getSnapshot(DIALOG, 'observer').approvalStatuses).toEqual({
|
|
232
|
+
'req-1': 'approved',
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('createChatDialogStore — snapshot purity + reducer lifecycle', () => {
|
|
238
|
+
it('getSnapshot on an unknown key creates NO entry (returns the shared frozen EMPTY_STATE)', () => {
|
|
239
|
+
const store = createChatDialogStore()
|
|
240
|
+
|
|
241
|
+
const a = store.getSnapshot('never-seen-a')
|
|
242
|
+
const b = store.getSnapshot('never-seen-b', 'observer')
|
|
243
|
+
// One shared instance across every absent key → nothing was created
|
|
244
|
+
// per-key, and useSyncExternalStore sees a stable identity.
|
|
245
|
+
expect(a).toBe(b)
|
|
246
|
+
expect(a).toBe(store.getServerSnapshot())
|
|
247
|
+
expect(Object.isFrozen(a)).toBe(true)
|
|
248
|
+
expect(a.messages).toEqual([])
|
|
249
|
+
expect(a.streamingPhase).toBe('idle')
|
|
250
|
+
|
|
251
|
+
// Behavioral proof that no entries were inserted: a real reducer created
|
|
252
|
+
// BEFORE far more phantom reads than the LRU cap survives untouched.
|
|
253
|
+
store.apply('real', 'main', { type: 'turn-start', seq: 1 })
|
|
254
|
+
store.apply('real', 'main', { type: 'text-delta', text: 'kept', seq: 2 })
|
|
255
|
+
for (let i = 0; i < DEFAULT_MAX_REDUCERS * 3; i += 1) {
|
|
256
|
+
store.getSnapshot(`phantom-${i}`)
|
|
257
|
+
}
|
|
258
|
+
const kept = store.getSnapshot('real', 'main')
|
|
259
|
+
expect(last(last(kept.messages)?.segments)).toEqual({ type: 'text', text: 'kept' })
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('evicts least-recently-used reducers past the cap, keeping the recently-applied ones', () => {
|
|
263
|
+
const store = createChatDialogStore({ maxReducers: 2 })
|
|
264
|
+
|
|
265
|
+
for (const id of ['d1', 'd2', 'd3']) {
|
|
266
|
+
finishedTurn(store, id, id)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// d1 is the LRU → evicted; its snapshot falls back to EMPTY_STATE.
|
|
270
|
+
expect(store.getSnapshot('d1', 'main').messages).toEqual([])
|
|
271
|
+
for (const id of ['d2', 'd3']) {
|
|
272
|
+
expect(last(last(store.getSnapshot(id, 'main').messages)?.segments)).toEqual({
|
|
273
|
+
type: 'text',
|
|
274
|
+
text: id,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('applying to an existing reducer refreshes its LRU position', () => {
|
|
280
|
+
const store = createChatDialogStore({ maxReducers: 2 })
|
|
281
|
+
finishedTurn(store, 'd1', 'd1')
|
|
282
|
+
finishedTurn(store, 'd2', 'd2')
|
|
283
|
+
// Touch d1 so d2 becomes the least-recently-used…
|
|
284
|
+
store.apply('d1', 'main', { type: 'text-delta', text: '!', seq: 5 })
|
|
285
|
+
store.apply('d1', 'main', { type: 'turn-end', seq: 6 })
|
|
286
|
+
// …then a third dialog evicts d2, not d1.
|
|
287
|
+
finishedTurn(store, 'd3', 'd3')
|
|
288
|
+
|
|
289
|
+
expect(store.getSnapshot('d2', 'main').messages).toEqual([])
|
|
290
|
+
expect(store.getSnapshot('d1', 'main').messages.length).toBeGreaterThan(0)
|
|
291
|
+
expect(store.getSnapshot('d3', 'main').messages.length).toBeGreaterThan(0)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
/** One complete (idle-at-the-end) turn — the only shape the LRU may evict. */
|
|
296
|
+
function finishedTurn(
|
|
297
|
+
store: ReturnType<typeof createChatDialogStore>,
|
|
298
|
+
dialogId: string,
|
|
299
|
+
text: string,
|
|
300
|
+
): void {
|
|
301
|
+
store.apply(dialogId, 'main', { type: 'turn-start', seq: 1 })
|
|
302
|
+
store.apply(dialogId, 'main', { type: 'text-delta', text, seq: 2 })
|
|
303
|
+
store.apply(dialogId, 'main', { type: 'turn-end', seq: 3 })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
describe('createChatDialogStore — eviction safety', () => {
|
|
307
|
+
it('NEVER evicts a reducer that is mid-stream (streamingPhase !== idle)', () => {
|
|
308
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
309
|
+
|
|
310
|
+
// A live turn on `live`: no turn-end, so the phase is still streaming.
|
|
311
|
+
store.apply('live', 'main', { type: 'turn-start', seq: 1 })
|
|
312
|
+
store.apply('live', 'main', { type: 'text-delta', text: 'half a sen', seq: 2 })
|
|
313
|
+
expect(store.getSnapshot('live', 'main').streamingPhase).not.toBe('idle')
|
|
314
|
+
|
|
315
|
+
// Ten other dialogs churn through the cap of 1.
|
|
316
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `other-${i}`, 'x')
|
|
317
|
+
|
|
318
|
+
// The live thread survives — dropping it would blank a visible thread
|
|
319
|
+
// that the host's seq cursor believes is already caught up.
|
|
320
|
+
const live = store.getSnapshot('live', 'main')
|
|
321
|
+
expect(live.messages.length).toBeGreaterThan(0)
|
|
322
|
+
expect(live.streamingPhase).not.toBe('idle')
|
|
323
|
+
|
|
324
|
+
// …and once the turn finishes it becomes evictable again.
|
|
325
|
+
store.apply('live', 'main', { type: 'turn-end', seq: 3 })
|
|
326
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `later-${i}`, 'x')
|
|
327
|
+
expect(store.getSnapshot('live', 'main').messages).toEqual([])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('NEVER evicts a RETAINED key, and evicts it again after release', () => {
|
|
331
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
332
|
+
finishedTurn(store, 'pinned', 'pinned-text')
|
|
333
|
+
const release = store.retain('pinned', 'main')
|
|
334
|
+
|
|
335
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `noise-${i}`, 'x')
|
|
336
|
+
expect(last(last(store.getSnapshot('pinned', 'main').messages)?.segments)).toEqual({
|
|
337
|
+
type: 'text',
|
|
338
|
+
text: 'pinned-text',
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
release()
|
|
342
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `more-${i}`, 'x')
|
|
343
|
+
expect(store.getSnapshot('pinned', 'main').messages).toEqual([])
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* REGRESSION (round 4): eviction used to be UNPUBLISHED, so hosts inferred
|
|
348
|
+
* it by comparing reducer OBJECT IDENTITY across `getReducer` calls and then
|
|
349
|
+
* suppressing the pristine-empty snapshot a silently-recreated reducer
|
|
350
|
+
* returns. The store knows the fact exactly — it states it.
|
|
351
|
+
*/
|
|
352
|
+
it('onEvict publishes the (dialogId, side) an LRU eviction dropped', () => {
|
|
353
|
+
const evicted: Array<[string, string]> = []
|
|
354
|
+
const store = createChatDialogStore({
|
|
355
|
+
maxReducers: 1,
|
|
356
|
+
onEvict: (dialogId, side) => evicted.push([dialogId, side]),
|
|
357
|
+
})
|
|
358
|
+
finishedTurn(store, 'first', 'a')
|
|
359
|
+
// Round-trip through a side whose name contains no separator ambiguity.
|
|
360
|
+
store.getReducer('second', 'observer')
|
|
361
|
+
expect(evicted).toEqual([['first', 'main']])
|
|
362
|
+
|
|
363
|
+
// Host-initiated removal is NOT an eviction — the caller already knows.
|
|
364
|
+
store.remove('second', 'observer')
|
|
365
|
+
expect(evicted).toEqual([['first', 'main']])
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* REGRESSION (round 5): the eviction handoff used to carry MESSAGES only, so
|
|
370
|
+
* a host re-seeding the recreated reducer got back a pristine
|
|
371
|
+
* `approvalStatuses = {}` and `lastAppliedSeq = -Infinity` — a resolved
|
|
372
|
+
* approval whose APPROVAL_RESULT row is not in the refetched history page
|
|
373
|
+
* re-renders as ACTIONABLE (exactly what `resetForDialogSwitch` preserves
|
|
374
|
+
* that map to prevent), and a replay from the host's cursor re-applies
|
|
375
|
+
* events the dropped instance had consumed.
|
|
376
|
+
*/
|
|
377
|
+
it('onEvict parks approvalStatuses + lastAppliedSeq, and the state round-trips', () => {
|
|
378
|
+
const parked: Array<{
|
|
379
|
+
approvalStatuses: Record<string, string>
|
|
380
|
+
lastAppliedSeq: number
|
|
381
|
+
messages: unknown[]
|
|
382
|
+
}> = []
|
|
383
|
+
const store = createChatDialogStore({
|
|
384
|
+
maxReducers: 1,
|
|
385
|
+
onEvict: (_dialogId, _side, state) =>
|
|
386
|
+
parked.push({
|
|
387
|
+
approvalStatuses: state.approvalStatuses,
|
|
388
|
+
lastAppliedSeq: state.lastAppliedSeq,
|
|
389
|
+
messages: state.messages,
|
|
390
|
+
}),
|
|
391
|
+
})
|
|
392
|
+
finishedTurn(store, 'first', 'a')
|
|
393
|
+
store.mutate('first', 'main', (r) => r.setApprovalStatus('req-1', 'approved'))
|
|
394
|
+
|
|
395
|
+
// Churn the cap so `first` is evicted.
|
|
396
|
+
store.getReducer('second', 'main')
|
|
397
|
+
expect(parked).toHaveLength(1)
|
|
398
|
+
expect(parked[0].approvalStatuses).toEqual({ 'req-1': 'approved' })
|
|
399
|
+
expect(parked[0].lastAppliedSeq).toBe(3)
|
|
400
|
+
expect(parked[0].messages.length).toBeGreaterThan(0)
|
|
401
|
+
|
|
402
|
+
// The recreated instance is pristine…
|
|
403
|
+
const recreated = store.getReducer('first', 'main')
|
|
404
|
+
expect(recreated.state.approvalStatuses).toEqual({})
|
|
405
|
+
expect(recreated.getLastAppliedSeq()).toBe(Number.NEGATIVE_INFINITY)
|
|
406
|
+
|
|
407
|
+
// …until the host restores the parked payload alongside the refetched
|
|
408
|
+
// messages, which is what the callback exists to make possible.
|
|
409
|
+
recreated.initializeWithState([], {
|
|
410
|
+
approvalStatuses: parked[0].approvalStatuses as never,
|
|
411
|
+
lastAppliedSeq: parked[0].lastAppliedSeq,
|
|
412
|
+
})
|
|
413
|
+
expect(recreated.state.approvalStatuses).toEqual({ 'req-1': 'approved' })
|
|
414
|
+
// The restored seq gate drops an already-applied replay.
|
|
415
|
+
recreated.apply({ type: 'text-delta', text: 'replayed', seq: 2 })
|
|
416
|
+
expect(recreated.state.messages).toEqual([])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A key dropped between `pushOptimisticSend` and its `MESSAGE_REQUEST` echo
|
|
421
|
+
* leaves the replacement reducer with NOTHING armed, so the echo renders a
|
|
422
|
+
* duplicate user bubble. The armed entries ride along in the parked payload.
|
|
423
|
+
*/
|
|
424
|
+
it('onEvict parks armed pendingEchoes, and echo dedup round-trips', () => {
|
|
425
|
+
const realNow = Date.now
|
|
426
|
+
try {
|
|
427
|
+
let now = 2_000_000
|
|
428
|
+
Date.now = () => now
|
|
429
|
+
const parked: EvictedReducerState[] = []
|
|
430
|
+
const store = createChatDialogStore({
|
|
431
|
+
maxReducers: 1,
|
|
432
|
+
defaultCreateOptions: () => ({
|
|
433
|
+
transport: 'nats',
|
|
434
|
+
ownEchoIncludesAdmin: true,
|
|
435
|
+
selfUserId: 'me',
|
|
436
|
+
}),
|
|
437
|
+
onEvict: (_dialogId, _side, state) => parked.push(state),
|
|
438
|
+
})
|
|
439
|
+
// A send made DURING a turn: the turn ends (phase → idle, so the key is
|
|
440
|
+
// evictable) while the send's echo is still in flight.
|
|
441
|
+
store.apply('first', 'main', { type: 'turn-start', seq: 1 })
|
|
442
|
+
now += 1
|
|
443
|
+
store.mutate('first', 'main', (r) => r.pushOptimisticSend('hello world'))
|
|
444
|
+
store.apply('first', 'main', { type: 'turn-end', seq: 2 })
|
|
445
|
+
|
|
446
|
+
store.getReducer('second', 'main')
|
|
447
|
+
expect(parked).toHaveLength(1)
|
|
448
|
+
expect(parked[0].pendingEchoes).toEqual([{ text: 'hello world', at: now }])
|
|
449
|
+
|
|
450
|
+
const recreated = store.getReducer('first', 'main')
|
|
451
|
+
recreated.initializeWithState(parked[0].messages as never, {
|
|
452
|
+
pendingEchoes: parked[0].pendingEchoes,
|
|
453
|
+
lastAppliedSeq: parked[0].lastAppliedSeq,
|
|
454
|
+
})
|
|
455
|
+
recreated.apply({
|
|
456
|
+
type: 'participant',
|
|
457
|
+
kind: 'message-request',
|
|
458
|
+
text: 'hello world',
|
|
459
|
+
ownerType: 'ADMIN',
|
|
460
|
+
userId: 'me',
|
|
461
|
+
seq: 3,
|
|
462
|
+
})
|
|
463
|
+
expect(recreated.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
464
|
+
} finally {
|
|
465
|
+
Date.now = realNow
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('setRetained pins exactly the given set and releases the rest', () => {
|
|
470
|
+
const store = createChatDialogStore({ maxReducers: 2 })
|
|
471
|
+
finishedTurn(store, 'a', 'a-text')
|
|
472
|
+
finishedTurn(store, 'b', 'b-text')
|
|
473
|
+
store.setRetained([
|
|
474
|
+
{ dialogId: 'a' },
|
|
475
|
+
{ dialogId: 'b', side: 'main' },
|
|
476
|
+
])
|
|
477
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `noise-${i}`, 'x')
|
|
478
|
+
expect(store.getSnapshot('a', 'main').messages.length).toBeGreaterThan(0)
|
|
479
|
+
expect(store.getSnapshot('b', 'main').messages.length).toBeGreaterThan(0)
|
|
480
|
+
|
|
481
|
+
// Swap the set: `b` stays pinned (never dips to zero), `a` is released.
|
|
482
|
+
store.setRetained([{ dialogId: 'b' }])
|
|
483
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `more-${i}`, 'x')
|
|
484
|
+
expect(store.getSnapshot('a', 'main').messages).toEqual([])
|
|
485
|
+
expect(store.getSnapshot('b', 'main').messages.length).toBeGreaterThan(0)
|
|
486
|
+
|
|
487
|
+
// Empty set releases everything.
|
|
488
|
+
store.setRetained([])
|
|
489
|
+
for (let i = 0; i < 10; i += 1) finishedTurn(store, `last-${i}`, 'x')
|
|
490
|
+
expect(store.getSnapshot('b', 'main').messages).toEqual([])
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('setRetained composes with (and never clobbers) a component retain()', () => {
|
|
494
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
495
|
+
finishedTurn(store, 'shared', 'shared-text')
|
|
496
|
+
const release = store.retain('shared')
|
|
497
|
+
store.setRetained([{ dialogId: 'shared' }])
|
|
498
|
+
|
|
499
|
+
// Dropping the policy retain leaves the component's hold standing.
|
|
500
|
+
store.setRetained([])
|
|
501
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `noise-${i}`, 'x')
|
|
502
|
+
expect(store.getSnapshot('shared', 'main').messages.length).toBeGreaterThan(0)
|
|
503
|
+
|
|
504
|
+
release()
|
|
505
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `more-${i}`, 'x')
|
|
506
|
+
expect(store.getSnapshot('shared', 'main').messages).toEqual([])
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('retain() is refcounted and its release is idempotent', () => {
|
|
510
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
511
|
+
finishedTurn(store, 'pinned', 'pinned-text')
|
|
512
|
+
const releaseA = store.retain('pinned')
|
|
513
|
+
const releaseB = store.retain('pinned')
|
|
514
|
+
|
|
515
|
+
releaseA()
|
|
516
|
+
releaseA() // idempotent — must NOT decrement B's hold
|
|
517
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `noise-${i}`, 'x')
|
|
518
|
+
expect(store.getSnapshot('pinned', 'main').messages.length).toBeGreaterThan(0)
|
|
519
|
+
|
|
520
|
+
releaseB()
|
|
521
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `more-${i}`, 'x')
|
|
522
|
+
expect(store.getSnapshot('pinned', 'main').messages).toEqual([])
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('eviction never notifies SYNCHRONOUSLY (getReducer runs in the render phase)', async () => {
|
|
526
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
527
|
+
finishedTurn(store, 'd1', 'd1')
|
|
528
|
+
|
|
529
|
+
let notifications = 0
|
|
530
|
+
store.subscribe(() => {
|
|
531
|
+
notifications += 1
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
// A render-phase `getReducer` for a NEW key evicts d1 — synchronously
|
|
535
|
+
// notifying here is React's "cannot update a component while rendering".
|
|
536
|
+
store.getReducer('d2', 'main')
|
|
537
|
+
expect(notifications).toBe(0)
|
|
538
|
+
await Promise.resolve()
|
|
539
|
+
await Promise.resolve()
|
|
540
|
+
expect(notifications).toBeGreaterThan(0)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* REGRESSION (round 3): `getReducer` inserted THEN evicted from the LRU
|
|
545
|
+
* head. A brand-new key is unretained (the hook retains in an effect, i.e.
|
|
546
|
+
* after render) and idle, so when every older key was protected the loop
|
|
547
|
+
* walked to the tail and dropped the key it had just created — `getReducer`
|
|
548
|
+
* handed back a DETACHED reducer whose mutations `getSnapshot` could never
|
|
549
|
+
* see.
|
|
550
|
+
*/
|
|
551
|
+
it('NEVER evicts the key getReducer just created (all older keys protected)', () => {
|
|
552
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
553
|
+
finishedTurn(store, 'pinned', 'pinned-text')
|
|
554
|
+
store.retain('pinned', 'main')
|
|
555
|
+
|
|
556
|
+
const reducer = store.getReducer('fresh', 'main')
|
|
557
|
+
reducer.setMessages([
|
|
558
|
+
{ id: 'm1', role: 'assistant', content: 'hello', segments: [{ type: 'text', text: 'hello' }] },
|
|
559
|
+
])
|
|
560
|
+
|
|
561
|
+
// The just-created reducer must still be the store's — not a detached
|
|
562
|
+
// instance whose key resolves to EMPTY_STATE.
|
|
563
|
+
expect(store.getReducer('fresh', 'main')).toBe(reducer)
|
|
564
|
+
expect(store.getSnapshot('fresh', 'main').messages).toHaveLength(1)
|
|
565
|
+
// The retained key survived too.
|
|
566
|
+
expect(store.getSnapshot('pinned', 'main').messages.length).toBeGreaterThan(0)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* REGRESSION (round 10): the round-3 guard protected only the ONE key
|
|
571
|
+
* `getReducer` was inserting. `useChatStreamReducer` calls `getReducer`
|
|
572
|
+
* during RENDER and `retain()` in an EFFECT, so React renders every panel of
|
|
573
|
+
* a commit before ANY retain lands — mounting `maxReducers + 1` panels with
|
|
574
|
+
* distinct dialogIds in one commit let render #(cap+1) evict the key render
|
|
575
|
+
* #1 had just created. Panel #1 kept its reducer object, but `getSnapshot`
|
|
576
|
+
* is a pure map read, so its thread rendered as EMPTY_STATE forever.
|
|
577
|
+
*/
|
|
578
|
+
it('protects EVERY key created in one commit, not just the newest (>MAX panels mounted together)', () => {
|
|
579
|
+
const max = 4
|
|
580
|
+
const evicted: string[] = []
|
|
581
|
+
const store = createChatDialogStore({
|
|
582
|
+
maxReducers: max,
|
|
583
|
+
onEvict: (dialogId) => evicted.push(dialogId),
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// One React commit: N renders, each a `getReducer`, ZERO retains yet.
|
|
587
|
+
const reducers = Array.from({ length: max + 3 }, (_, i) =>
|
|
588
|
+
store.getReducer(`panel-${i}`, 'main'),
|
|
589
|
+
)
|
|
590
|
+
reducers.forEach((r, i) =>
|
|
591
|
+
r.setMessages([
|
|
592
|
+
{ id: `m-${i}`, role: 'assistant', content: `thread ${i}`, segments: [{ type: 'text', text: `thread ${i}` }] },
|
|
593
|
+
]),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
// Nothing was dropped, and every panel still sees its OWN thread.
|
|
597
|
+
expect(evicted).toEqual([])
|
|
598
|
+
reducers.forEach((r, i) => {
|
|
599
|
+
expect(store.getReducer(`panel-${i}`, 'main')).toBe(r)
|
|
600
|
+
expect(last(store.getSnapshot(`panel-${i}`, 'main').messages)?.content).toBe(`thread ${i}`)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// The effects that follow the commit pin what is still mounted.
|
|
604
|
+
reducers.forEach((_, i) => store.retain(`panel-${i}`, 'main'))
|
|
605
|
+
reducers.forEach((_, i) =>
|
|
606
|
+
expect(store.getSnapshot(`panel-${i}`, 'main').messages).toHaveLength(1),
|
|
607
|
+
)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
/** The group protection is a stand-in for the retain that has not landed
|
|
611
|
+
* yet, not a licence to grow: once the retains land (and the panels later
|
|
612
|
+
* unmount), the LRU catches all the way back down to the cap. */
|
|
613
|
+
it('resumes evicting to the cap once the pending retains land', () => {
|
|
614
|
+
const store = createChatDialogStore({ maxReducers: 2 })
|
|
615
|
+
const ids = ['p-0', 'p-1', 'p-2', 'p-3', 'p-4']
|
|
616
|
+
const reducers = ids.map((id) => store.getReducer(id, 'main'))
|
|
617
|
+
expect(reducers).toHaveLength(5)
|
|
618
|
+
|
|
619
|
+
// The effects run (retain), then the panels unmount (release).
|
|
620
|
+
ids.map((id) => store.retain(id, 'main')).forEach((release) => release())
|
|
621
|
+
|
|
622
|
+
// A later, unrelated commit creates one more key — the now-unprotected
|
|
623
|
+
// ones are evictable again.
|
|
624
|
+
store.getReducer('p-late', 'main')
|
|
625
|
+
expect(store.getSnapshot('p-0', 'main')).toBe(store.getSnapshot('nope', 'main'))
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* REGRESSION (round 11): the round-10 group protection expired on a
|
|
630
|
+
* MICROTASK, on the assumption that a render/commit pass never yields to the
|
|
631
|
+
* microtask queue. Under CONCURRENT rendering React time-slices — it suspends
|
|
632
|
+
* the render and resumes in a later macrotask, flushing microtasks in between
|
|
633
|
+
* — so keys created before the yield lost their protection while their
|
|
634
|
+
* `retain()` was still pending, and the resumed render evicted them: the
|
|
635
|
+
* exact blank-thread defect round 10 closed. Protection now ends at the first
|
|
636
|
+
* `retain()`, an event that cannot be re-ordered by the scheduler.
|
|
637
|
+
*/
|
|
638
|
+
it('keeps pending-retain keys protected across a mid-render microtask yield', async () => {
|
|
639
|
+
const max = 2
|
|
640
|
+
const evicted: string[] = []
|
|
641
|
+
const store = createChatDialogStore({
|
|
642
|
+
maxReducers: max,
|
|
643
|
+
onEvict: (dialogId) => evicted.push(dialogId),
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
const early = Array.from({ length: max }, (_, i) => store.getReducer(`panel-${i}`, 'main'))
|
|
647
|
+
early.forEach((r, i) =>
|
|
648
|
+
r.setMessages([
|
|
649
|
+
{
|
|
650
|
+
id: `m-${i}`,
|
|
651
|
+
role: 'assistant',
|
|
652
|
+
content: `thread ${i}`,
|
|
653
|
+
segments: [{ type: 'text', text: `thread ${i}` }],
|
|
654
|
+
},
|
|
655
|
+
]),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
// React yields mid-render; the microtask queue drains before it resumes.
|
|
659
|
+
await Promise.resolve()
|
|
660
|
+
await Promise.resolve()
|
|
661
|
+
|
|
662
|
+
// …the SAME render resumes and mounts the remaining panels.
|
|
663
|
+
const late = [store.getReducer('panel-2', 'main'), store.getReducer('panel-3', 'main')]
|
|
664
|
+
expect(late).toHaveLength(2)
|
|
665
|
+
|
|
666
|
+
// No retain has landed yet, so nothing may have been dropped.
|
|
667
|
+
expect(evicted).toEqual([])
|
|
668
|
+
early.forEach((r, i) => {
|
|
669
|
+
expect(store.getReducer(`panel-${i}`, 'main')).toBe(r)
|
|
670
|
+
expect(last(store.getSnapshot(`panel-${i}`, 'main').messages)?.content).toBe(`thread ${i}`)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
// The effects that follow the commit pin what is still mounted.
|
|
674
|
+
early.forEach((_, i) => store.retain(`panel-${i}`, 'main'))
|
|
675
|
+
early.forEach((_, i) =>
|
|
676
|
+
expect(store.getSnapshot(`panel-${i}`, 'main').messages).toHaveLength(1),
|
|
677
|
+
)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
/** Protection stands in for a retain that has not landed. The moment it
|
|
681
|
+
* lands the key is protected by the RETAIN COUNT instead, so a release must
|
|
682
|
+
* make the key evictable again inside the same task — no timer involved. */
|
|
683
|
+
it('drops the pending-retain protection at the first retain(), not on a timer', () => {
|
|
684
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
685
|
+
store.getReducer('a', 'main').setMessages([
|
|
686
|
+
{ id: 'm1', role: 'assistant', content: 'a-text', segments: [{ type: 'text', text: 'a-text' }] },
|
|
687
|
+
])
|
|
688
|
+
|
|
689
|
+
// The effect lands, then the panel unmounts — all in the same task.
|
|
690
|
+
store.retain('a', 'main')()
|
|
691
|
+
|
|
692
|
+
store.getReducer('b', 'main')
|
|
693
|
+
expect(store.getSnapshot('a', 'main').messages).toEqual([])
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The protected set is itself bounded (`MAX_PENDING_RETAIN_KEYS`), so a
|
|
698
|
+
* caller that reads a reducer once and drops it — never retaining — cannot
|
|
699
|
+
* grow the map without limit. Beyond the bound the oldest pending key falls
|
|
700
|
+
* through to ordinary LRU.
|
|
701
|
+
*/
|
|
702
|
+
it('bounds the map when thousands of keys are created without ever retaining', () => {
|
|
703
|
+
const total = MAX_PENDING_RETAIN_KEYS * 4
|
|
704
|
+
const evicted = new Set<string>()
|
|
705
|
+
const store = createChatDialogStore({
|
|
706
|
+
maxReducers: 10,
|
|
707
|
+
onEvict: (dialogId) => evicted.add(dialogId),
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
for (let i = 0; i < total; i += 1) store.getReducer(`d-${i}`, 'main')
|
|
711
|
+
|
|
712
|
+
const survivors = Array.from({ length: total }, (_, i) => `d-${i}`).filter(
|
|
713
|
+
(id) => !evicted.has(id),
|
|
714
|
+
)
|
|
715
|
+
expect(survivors).toHaveLength(MAX_PENDING_RETAIN_KEYS)
|
|
716
|
+
expect(survivors).toEqual(
|
|
717
|
+
Array.from(
|
|
718
|
+
{ length: MAX_PENDING_RETAIN_KEYS },
|
|
719
|
+
(_, i) => `d-${total - MAX_PENDING_RETAIN_KEYS + i}`,
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
/** Event-phase creations are NOT group-protected: nobody holds those
|
|
725
|
+
* instances past the call, so a burst of events across many dialogs must
|
|
726
|
+
* still evict down to the cap inside the same task. */
|
|
727
|
+
it('apply()-created keys still evict synchronously within one task', () => {
|
|
728
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
729
|
+
finishedTurn(store, 'd1', 'd1-text')
|
|
730
|
+
finishedTurn(store, 'd2', 'd2-text')
|
|
731
|
+
finishedTurn(store, 'd3', 'd3-text')
|
|
732
|
+
expect(store.getSnapshot('d1', 'main').messages).toEqual([])
|
|
733
|
+
expect(store.getSnapshot('d3', 'main').messages.length).toBeGreaterThan(0)
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
/** `apply()`/`mutate()` can be a key's FIRST toucher; the reducer they
|
|
737
|
+
* create must carry the host's semantics, since create-options are
|
|
738
|
+
* consulted once and a later `getReducer(..., options)` gets that same
|
|
739
|
+
* instance. */
|
|
740
|
+
it('reducers first created by apply() use the store-level defaultCreateOptions', () => {
|
|
741
|
+
const store = createChatDialogStore({
|
|
742
|
+
defaultCreateOptions: () => ({ transport: 'nats', ownEchoIncludesAdmin: true }),
|
|
743
|
+
})
|
|
744
|
+
// First touch is an EVENT, not a render.
|
|
745
|
+
store.apply('d1', 'main', { type: 'participant', kind: 'system', text: 'joined', seq: 1 })
|
|
746
|
+
store.mutate('d1', 'main', (r) => r.pushOptimisticSend('ok'))
|
|
747
|
+
store.apply('d1', 'main', {
|
|
748
|
+
type: 'participant',
|
|
749
|
+
kind: 'message-request',
|
|
750
|
+
text: 'ok',
|
|
751
|
+
ownerType: 'ADMIN',
|
|
752
|
+
seq: 2,
|
|
753
|
+
})
|
|
754
|
+
// ownEchoIncludesAdmin was honoured → the ADMIN echo was consumed.
|
|
755
|
+
expect(store.getSnapshot('d1', 'main').messages.filter((m) => m.content === 'ok')).toHaveLength(1)
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('remove() notifies only when something was actually deleted', () => {
|
|
759
|
+
const store = createChatDialogStore()
|
|
760
|
+
finishedTurn(store, 'd1', 'd1')
|
|
761
|
+
|
|
762
|
+
let notifications = 0
|
|
763
|
+
store.subscribe(() => {
|
|
764
|
+
notifications += 1
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
store.remove('never-existed')
|
|
768
|
+
store.remove('never-existed', 'main')
|
|
769
|
+
expect(notifications).toBe(0)
|
|
770
|
+
|
|
771
|
+
store.remove('d1', 'main')
|
|
772
|
+
expect(notifications).toBe(1)
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* A retain is a claim on the KEY, not on the reducer instance — so it
|
|
777
|
+
* deliberately SURVIVES `remove()` (and the recreate that usually follows).
|
|
778
|
+
* Clearing the retain counter in `remove()` would unpin a key its consumer is
|
|
779
|
+
* still holding; the counter is bounded by live retainers and drains when the
|
|
780
|
+
* last release runs.
|
|
781
|
+
*/
|
|
782
|
+
it('retain() survives remove() and still pins the recreated key', () => {
|
|
783
|
+
const store = createChatDialogStore({ maxReducers: 1 })
|
|
784
|
+
finishedTurn(store, 'd1', 'd1-text')
|
|
785
|
+
const releaseA = store.retain('d1')
|
|
786
|
+
const releaseB = store.retain('d1')
|
|
787
|
+
|
|
788
|
+
store.remove('d1', 'main')
|
|
789
|
+
expect(store.getSnapshot('d1', 'main').messages).toEqual([])
|
|
790
|
+
|
|
791
|
+
// Recreated key: the retains taken before the removal still protect it.
|
|
792
|
+
finishedTurn(store, 'd1', 'd1-again')
|
|
793
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `noise-${i}`, 'x')
|
|
794
|
+
expect(store.getSnapshot('d1', 'main').messages.length).toBeGreaterThan(0)
|
|
795
|
+
|
|
796
|
+
// Refcount was NOT reset by remove(): one release still leaves B's hold.
|
|
797
|
+
releaseA()
|
|
798
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `more-${i}`, 'x')
|
|
799
|
+
expect(store.getSnapshot('d1', 'main').messages.length).toBeGreaterThan(0)
|
|
800
|
+
|
|
801
|
+
// Last release drains the entry — the key becomes evictable again.
|
|
802
|
+
releaseB()
|
|
803
|
+
for (let i = 0; i < 5; i += 1) finishedTurn(store, `last-${i}`, 'x')
|
|
804
|
+
expect(store.getSnapshot('d1', 'main').messages).toEqual([])
|
|
805
|
+
})
|
|
806
|
+
})
|