@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,1330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-3 tests — `createChatStreamReducer`, the master chat-stream reader.
|
|
3
|
+
*
|
|
4
|
+
* The golden characterization suites (chunk-processor / SSE-stream /
|
|
5
|
+
* accumulator / history / persisted-v1) pin transport parity through the
|
|
6
|
+
* public hooks; THIS file pins the reducer-specific contracts the plan
|
|
7
|
+
* added on top:
|
|
8
|
+
*
|
|
9
|
+
* - seq-based idempotency (drop `seq` ≤ last applied, per instance);
|
|
10
|
+
* - REFERENTIAL STABILITY: untouched messages keep Object.is identity
|
|
11
|
+
* across applies AND across replayed duplicate events (value-level
|
|
12
|
+
* no-op merges return prior references);
|
|
13
|
+
* - `resolvePendingApprovalForExecution` (implicit approve-on-execution)
|
|
14
|
+
* is a NATS-only semantic — the SSE kernel never triggers it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
18
|
+
import {
|
|
19
|
+
createChatStreamReducer,
|
|
20
|
+
OWN_ECHO_AUTHOR_TTL_MS,
|
|
21
|
+
OWN_ECHO_TTL_MS,
|
|
22
|
+
} from '../chat-stream-reducer'
|
|
23
|
+
import type { ChatStreamEvent } from '../../../../chat-protocol/events'
|
|
24
|
+
|
|
25
|
+
const executing = (execId = 'exec-1', seq?: number): ChatStreamEvent => ({
|
|
26
|
+
type: 'tool-execution',
|
|
27
|
+
data: {
|
|
28
|
+
type: 'EXECUTING_TOOL',
|
|
29
|
+
integratedToolType: 'SHELL',
|
|
30
|
+
toolFunction: 'run_command',
|
|
31
|
+
toolTitle: 'Run command',
|
|
32
|
+
parameters: { cmd: 'ls' },
|
|
33
|
+
toolExecutionRequestId: execId,
|
|
34
|
+
},
|
|
35
|
+
...(seq != null ? { seq } : {}),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const executed = (execId = 'exec-1', seq?: number): ChatStreamEvent => ({
|
|
39
|
+
type: 'tool-execution',
|
|
40
|
+
data: {
|
|
41
|
+
type: 'EXECUTED_TOOL',
|
|
42
|
+
integratedToolType: 'SHELL',
|
|
43
|
+
toolFunction: 'run_command',
|
|
44
|
+
result: 'ok',
|
|
45
|
+
success: true,
|
|
46
|
+
toolExecutionRequestId: execId,
|
|
47
|
+
},
|
|
48
|
+
...(seq != null ? { seq } : {}),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('createChatStreamReducer — seq idempotency', () => {
|
|
52
|
+
it('drops events whose seq is ≤ the last applied seq', () => {
|
|
53
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
54
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
55
|
+
r.apply({ type: 'text-delta', text: 'a', seq: 2 })
|
|
56
|
+
r.apply({ type: 'text-delta', text: 'b', seq: 3 })
|
|
57
|
+
// Redelivery of seq 3 and an out-of-order seq 2 must both be dropped.
|
|
58
|
+
r.apply({ type: 'text-delta', text: 'b', seq: 3 })
|
|
59
|
+
r.apply({ type: 'text-delta', text: 'a', seq: 2 })
|
|
60
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
61
|
+
expect(last.segments).toEqual([{ type: 'text', text: 'ab' }])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('seq-less events are never gated', () => {
|
|
65
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
66
|
+
r.apply({ type: 'turn-start', seq: 5 })
|
|
67
|
+
r.apply({ type: 'text-delta', text: 'x' })
|
|
68
|
+
r.apply({ type: 'text-delta', text: 'x' })
|
|
69
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
70
|
+
expect(last.segments).toEqual([{ type: 'text', text: 'xx' }])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('createChatStreamReducer — referential stability', () => {
|
|
75
|
+
it('applies reclone ONLY the touched message; earlier messages keep identity', () => {
|
|
76
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
77
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'hello', seq: 1 })
|
|
78
|
+
r.apply({ type: 'turn-start', seq: 2 })
|
|
79
|
+
r.apply({ type: 'text-delta', text: 'first reply', seq: 3 })
|
|
80
|
+
r.apply({ type: 'turn-end', seq: 4 })
|
|
81
|
+
const [userBefore, assistantBefore] = r.state.messages
|
|
82
|
+
|
|
83
|
+
// A post-END continuation appends into the trailing assistant only.
|
|
84
|
+
r.apply({ type: 'text-delta', text: ' …more', seq: 5 })
|
|
85
|
+
const [userAfter, assistantAfter] = r.state.messages
|
|
86
|
+
expect(userAfter).toBe(userBefore)
|
|
87
|
+
expect(assistantAfter).not.toBe(assistantBefore)
|
|
88
|
+
expect(assistantAfter.segments).toEqual([{ type: 'text', text: 'first reply …more' }])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('a replayed duplicate EXECUTED event (same seq) is dropped — full state identity holds', () => {
|
|
92
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
93
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
94
|
+
r.apply({ type: 'text-delta', text: 'running', seq: 2 })
|
|
95
|
+
r.apply({ type: 'turn-end', seq: 3 })
|
|
96
|
+
r.apply(executing('exec-1', 4))
|
|
97
|
+
r.apply(executed('exec-1', 5))
|
|
98
|
+
const messagesBefore = r.state.messages
|
|
99
|
+
|
|
100
|
+
r.apply(executed('exec-1', 5)) // JetStream redelivery
|
|
101
|
+
expect(r.state.messages).toBe(messagesBefore)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('a replayed seq-less duplicate EXECUTED merge is a value-level no-op — prior references returned', () => {
|
|
105
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
106
|
+
r.apply({ type: 'turn-start' })
|
|
107
|
+
r.apply({ type: 'text-delta', text: 'running' })
|
|
108
|
+
r.apply({ type: 'turn-end' })
|
|
109
|
+
r.apply(executing())
|
|
110
|
+
r.apply(executed())
|
|
111
|
+
const messagesBefore = r.state.messages
|
|
112
|
+
const trailingBefore = messagesBefore[messagesBefore.length - 1]
|
|
113
|
+
|
|
114
|
+
// Same values, new event object, no seq (plain-NATS replay).
|
|
115
|
+
r.apply(executed())
|
|
116
|
+
const messagesAfter = r.state.messages
|
|
117
|
+
expect(messagesAfter).toBe(messagesBefore)
|
|
118
|
+
expect(messagesAfter[messagesAfter.length - 1]).toBe(trailingBefore)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('a redelivered EXECUTING after EXECUTED never downgrades (prior references returned)', () => {
|
|
122
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
123
|
+
r.apply(executing())
|
|
124
|
+
r.apply(executed())
|
|
125
|
+
const before = r.state.messages
|
|
126
|
+
r.apply(executing())
|
|
127
|
+
expect(r.state.messages).toBe(before)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('createChatStreamReducer — implicit approve-on-execution transport gate', () => {
|
|
132
|
+
const approvalRequest: ChatStreamEvent = {
|
|
133
|
+
type: 'approval-request',
|
|
134
|
+
requestId: 'req-1',
|
|
135
|
+
approvalType: 'CLIENT',
|
|
136
|
+
command: 'systemctl restart nats',
|
|
137
|
+
explanation: 'Restart the broker',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it("transport 'nats': an in-stream tool execution implicitly approves the pending gate", () => {
|
|
141
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
142
|
+
r.apply({ type: 'turn-start' })
|
|
143
|
+
r.apply(approvalRequest)
|
|
144
|
+
// Observer stream: EXECUTING arrives with NO APPROVAL_RESULT chunk.
|
|
145
|
+
r.apply(executing())
|
|
146
|
+
const trailing = r.state.messages[r.state.messages.length - 1]
|
|
147
|
+
const card = trailing.segments?.find((s) => s.type === 'approval_request')
|
|
148
|
+
expect(card).toBeDefined()
|
|
149
|
+
expect((card as { status?: string }).status).toBe('approved')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("transport 'sse': tool-execution events are ignored — a pending card stays pending", () => {
|
|
153
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
154
|
+
r.beginSseSend({ text: 'open a ticket', assistantName: 'Mingo AI' })
|
|
155
|
+
r.apply({
|
|
156
|
+
type: 'approval-request',
|
|
157
|
+
requestId: 'prop-1',
|
|
158
|
+
approvalType: 'create_ticket',
|
|
159
|
+
command: 'Create ticket',
|
|
160
|
+
fields: [{ label: 'Subject', value: 'Printer down' }],
|
|
161
|
+
})
|
|
162
|
+
const before = r.state.messages
|
|
163
|
+
r.apply(executing())
|
|
164
|
+
// Ignored entirely: same references, card untouched.
|
|
165
|
+
expect(r.state.messages).toBe(before)
|
|
166
|
+
const trailing = r.state.messages[r.state.messages.length - 1]
|
|
167
|
+
const card = trailing.segments?.find((s) => s.type === 'approval_request')
|
|
168
|
+
expect((card as { status?: string }).status).toBe('pending')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('createChatStreamReducer — SSE kernel', () => {
|
|
173
|
+
it('accumulates text cumulatively, keeps one thinking segment at the front, escapes tags', () => {
|
|
174
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
175
|
+
r.beginSseSend({ text: 'q', assistantName: 'Mingo AI' })
|
|
176
|
+
r.apply({ type: 'thinking-delta', text: 'checking <docs>… ' })
|
|
177
|
+
r.apply({ type: 'turn-start' })
|
|
178
|
+
r.apply({ type: 'text-delta', text: 'Answer ' })
|
|
179
|
+
r.apply({ type: 'thinking-delta', text: 'found it.' })
|
|
180
|
+
r.apply({ type: 'text-delta', text: 'body.' })
|
|
181
|
+
const trailing = r.state.messages[r.state.messages.length - 1]
|
|
182
|
+
expect(trailing.segments).toEqual([
|
|
183
|
+
{ type: 'thinking', text: 'checking <docs>… found it.' },
|
|
184
|
+
{ type: 'text', text: 'Answer body.' },
|
|
185
|
+
])
|
|
186
|
+
expect(r.state.streamingPhase).toBe('streaming')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('decision_resolved flips the SOURCE card, writes the receipt, and stamps chatRefs', () => {
|
|
190
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
191
|
+
// Turn 1: approval card.
|
|
192
|
+
r.beginSseSend({ text: 'open a ticket', assistantName: 'Mingo AI' })
|
|
193
|
+
r.apply({
|
|
194
|
+
type: 'approval-request',
|
|
195
|
+
requestId: 'prop-1',
|
|
196
|
+
approvalType: 'create_ticket',
|
|
197
|
+
command: 'Create ticket',
|
|
198
|
+
fields: [],
|
|
199
|
+
})
|
|
200
|
+
r.endSseTurn()
|
|
201
|
+
// Turn 2: hidden approval-action send → decision_resolved frame.
|
|
202
|
+
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
|
|
203
|
+
r.apply({
|
|
204
|
+
type: 'approval-resolved',
|
|
205
|
+
requestId: 'prop-1',
|
|
206
|
+
status: 'approved',
|
|
207
|
+
ok: true,
|
|
208
|
+
receiptText: '✅ Approved — ticket created: [card://ticket:77]',
|
|
209
|
+
cardRef: { type: 'ticket', id: '77', title: 'Broken printer' },
|
|
210
|
+
cardType: 'ticket',
|
|
211
|
+
})
|
|
212
|
+
r.endSseTurn()
|
|
213
|
+
|
|
214
|
+
// Three rows, not four: the hidden approval-action send is out-of-band
|
|
215
|
+
// metadata and mints NO user row (it would render as a bare author label).
|
|
216
|
+
expect(r.state.messages).toHaveLength(3)
|
|
217
|
+
const [, sourceMsg, receiptMsg] = r.state.messages
|
|
218
|
+
const card = sourceMsg.segments?.find((s) => s.type === 'approval_request')
|
|
219
|
+
expect((card as { status?: string }).status).toBe('approved')
|
|
220
|
+
expect(receiptMsg.segments).toEqual([
|
|
221
|
+
{ type: 'text', text: '✅ Approved — ticket created: [card://ticket:77]\n\n' },
|
|
222
|
+
])
|
|
223
|
+
expect(receiptMsg.chatRefs?.['ticket:77']?.id).toBe('77')
|
|
224
|
+
// Per-send refs map got the ref too (sendIdx 1 — the hidden send).
|
|
225
|
+
expect(r.state.turnMeta.refs.get(1)?.['ticket:77']?.id).toBe('77')
|
|
226
|
+
expect(r.state.streamingPhase).toBe('idle')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('endSseTurn drops an empty trailing placeholder (reject path)', () => {
|
|
230
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
231
|
+
r.beginSseSend({ text: 'q', assistantName: 'Mingo AI' })
|
|
232
|
+
r.apply({ type: 'text-delta', text: 'answer' })
|
|
233
|
+
r.endSseTurn()
|
|
234
|
+
// Reject: an empty-text (approval-action) send adds ONLY the assistant
|
|
235
|
+
// placeholder…
|
|
236
|
+
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
|
|
237
|
+
expect(r.state.messages).toHaveLength(3)
|
|
238
|
+
expect(r.state.messages[2].role).toBe('assistant')
|
|
239
|
+
// …and the reject frame streams nothing, so the turn leaves the thread
|
|
240
|
+
// exactly as it was — no bare user label, no blank bubble.
|
|
241
|
+
r.endSseTurn()
|
|
242
|
+
expect(r.state.messages).toHaveLength(2)
|
|
243
|
+
expect(r.state.messages.map((m) => m.role)).toEqual(['user', 'assistant'])
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('createChatStreamReducer — participant dedup', () => {
|
|
248
|
+
it('optimistic echo consumes exactly one MESSAGE_REQUEST twin', () => {
|
|
249
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
250
|
+
r.pushOptimisticSend('yes')
|
|
251
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
252
|
+
// Echo of our own send → consumed, no duplicate row.
|
|
253
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'yes' })
|
|
254
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
255
|
+
// A genuinely repeated send from another session still renders.
|
|
256
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'yes', seq: 10 })
|
|
257
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('ADMIN echo is NOT consumed by default (it is a technician reply)', () => {
|
|
261
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
262
|
+
r.pushOptimisticSend('same words')
|
|
263
|
+
// An ADMIN-authored row that happens to match our text is somebody
|
|
264
|
+
// else's message on hosts where the operator is not the admin.
|
|
265
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN' })
|
|
266
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('ADMIN echo IS consumed when the host declares ownEchoIncludesAdmin', () => {
|
|
270
|
+
const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
|
|
271
|
+
r.pushOptimisticSend('same words')
|
|
272
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN' })
|
|
273
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
274
|
+
// Still one-shot: a second ADMIN row with the same text renders.
|
|
275
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN', seq: 11 })
|
|
276
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('participant seen-seq set dedups redelivered rows', () => {
|
|
280
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
281
|
+
r.apply({ type: 'participant', kind: 'direct-message', text: 'human here', seq: 7 })
|
|
282
|
+
const before = r.state.messages
|
|
283
|
+
r.apply({ type: 'participant', kind: 'direct-message', text: 'human here', seq: 7 })
|
|
284
|
+
expect(r.state.messages).toBe(before)
|
|
285
|
+
expect(r.state.messages).toHaveLength(1)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* REGRESSION (round 3): on a shared ADMIN side, raw-text echo matching
|
|
290
|
+
* could delete a SECOND technician's message. Tech A sends "ok", A's echo
|
|
291
|
+
* never lands; Tech B sends "ok" → ADMIN-authored, matched the stale entry,
|
|
292
|
+
* consumed, never rendered for A.
|
|
293
|
+
*/
|
|
294
|
+
it('selfUserId: another author\'s identical text never consumes our echo', () => {
|
|
295
|
+
const r = createChatStreamReducer({
|
|
296
|
+
transport: 'nats',
|
|
297
|
+
ownEchoIncludesAdmin: true,
|
|
298
|
+
selfUserId: 'tech-a',
|
|
299
|
+
})
|
|
300
|
+
r.pushOptimisticSend('ok')
|
|
301
|
+
// Tech B's message — same text, same ADMIN owner type, different author.
|
|
302
|
+
r.apply({
|
|
303
|
+
type: 'participant',
|
|
304
|
+
kind: 'message-request',
|
|
305
|
+
text: 'ok',
|
|
306
|
+
ownerType: 'ADMIN',
|
|
307
|
+
userId: 'tech-b',
|
|
308
|
+
seq: 1,
|
|
309
|
+
})
|
|
310
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
311
|
+
// Our OWN echo still dedups.
|
|
312
|
+
r.apply({
|
|
313
|
+
type: 'participant',
|
|
314
|
+
kind: 'message-request',
|
|
315
|
+
text: 'ok',
|
|
316
|
+
ownerType: 'ADMIN',
|
|
317
|
+
userId: 'tech-a',
|
|
318
|
+
seq: 2,
|
|
319
|
+
})
|
|
320
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* REGRESSION (round 4): the author guard must fail OPEN. A transport whose
|
|
325
|
+
* decoder does not surface the author id (the ticket wire model declares it
|
|
326
|
+
* at `owner.userId`; the app's id may live in another id space entirely)
|
|
327
|
+
* would otherwise never dedup — every send rendered TWICE, strictly worse
|
|
328
|
+
* than the message-theft bug the guard fixes.
|
|
329
|
+
*/
|
|
330
|
+
it('selfUserId: an id-LESS row still dedups via the text+TTL fallback (fails open)', () => {
|
|
331
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
332
|
+
try {
|
|
333
|
+
const r = createChatStreamReducer({
|
|
334
|
+
transport: 'nats',
|
|
335
|
+
ownEchoIncludesAdmin: true,
|
|
336
|
+
selfUserId: 'tech-a',
|
|
337
|
+
})
|
|
338
|
+
r.pushOptimisticSend('on it')
|
|
339
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'on it', ownerType: 'ADMIN', seq: 1 })
|
|
340
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
341
|
+
// The misconfiguration is surfaced — once, not per row.
|
|
342
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
343
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain('selfUserId')
|
|
344
|
+
r.pushOptimisticSend('again')
|
|
345
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'again', ownerType: 'ADMIN', seq: 2 })
|
|
346
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
347
|
+
} finally {
|
|
348
|
+
warn.mockRestore()
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('selfUserId accepts a GETTER resolved at event time (late auth / user switch)', () => {
|
|
353
|
+
let self: string | undefined
|
|
354
|
+
const r = createChatStreamReducer({
|
|
355
|
+
transport: 'nats',
|
|
356
|
+
ownEchoIncludesAdmin: true,
|
|
357
|
+
selfUserId: () => self,
|
|
358
|
+
})
|
|
359
|
+
// Auth rehydrates AFTER the reducer was created.
|
|
360
|
+
self = 'tech-a'
|
|
361
|
+
r.pushOptimisticSend('ok')
|
|
362
|
+
r.apply({
|
|
363
|
+
type: 'participant',
|
|
364
|
+
kind: 'message-request',
|
|
365
|
+
text: 'ok',
|
|
366
|
+
ownerType: 'ADMIN',
|
|
367
|
+
userId: 'tech-b',
|
|
368
|
+
seq: 1,
|
|
369
|
+
})
|
|
370
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
371
|
+
// Log out, log in as somebody else — the guard tracks it without a reload.
|
|
372
|
+
self = 'tech-b'
|
|
373
|
+
r.pushOptimisticSend('ok')
|
|
374
|
+
r.apply({
|
|
375
|
+
type: 'participant',
|
|
376
|
+
kind: 'message-request',
|
|
377
|
+
text: 'ok',
|
|
378
|
+
ownerType: 'ADMIN',
|
|
379
|
+
userId: 'tech-b',
|
|
380
|
+
seq: 2,
|
|
381
|
+
})
|
|
382
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(3)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* REGRESSION (round 4): a JetStream catch-up replay after a >30s network gap
|
|
387
|
+
* delivers our own echo long after the send. Its rows carry `seq`, so the
|
|
388
|
+
* seq-less content-dedup fallback cannot rescue it — expiring an
|
|
389
|
+
* AUTHOR-MATCHED entry at the short TTL means a guaranteed duplicate row.
|
|
390
|
+
*/
|
|
391
|
+
it('an AUTHOR-MATCHED echo is consumed well past the unattributed TTL', () => {
|
|
392
|
+
const realNow = Date.now
|
|
393
|
+
try {
|
|
394
|
+
let now = 1_000_000
|
|
395
|
+
Date.now = () => now
|
|
396
|
+
const r = createChatStreamReducer({
|
|
397
|
+
transport: 'nats',
|
|
398
|
+
ownEchoIncludesAdmin: true,
|
|
399
|
+
selfUserId: 'tech-a',
|
|
400
|
+
})
|
|
401
|
+
r.pushOptimisticSend('on it')
|
|
402
|
+
now += OWN_ECHO_TTL_MS + 1_000
|
|
403
|
+
r.apply({
|
|
404
|
+
type: 'participant',
|
|
405
|
+
kind: 'message-request',
|
|
406
|
+
text: 'on it',
|
|
407
|
+
ownerType: 'ADMIN',
|
|
408
|
+
userId: 'tech-a',
|
|
409
|
+
seq: 9,
|
|
410
|
+
})
|
|
411
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
412
|
+
// Still one-shot — a second author-matched row with the same text renders.
|
|
413
|
+
r.apply({
|
|
414
|
+
type: 'participant',
|
|
415
|
+
kind: 'message-request',
|
|
416
|
+
text: 'on it',
|
|
417
|
+
ownerType: 'ADMIN',
|
|
418
|
+
userId: 'tech-a',
|
|
419
|
+
seq: 10,
|
|
420
|
+
})
|
|
421
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
422
|
+
} finally {
|
|
423
|
+
Date.now = realNow
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* REGRESSION (round 5): the author check rules out a COLLEAGUE's message, not
|
|
429
|
+
* the SAME user on a second tab. An entry whose echo never landed used to
|
|
430
|
+
* stay armed forever on the author-matched path, so the same user's identical
|
|
431
|
+
* send from another tab an hour later was silently DROPPED — message loss,
|
|
432
|
+
* strictly worse than the duplicate row the bypass was avoiding.
|
|
433
|
+
*/
|
|
434
|
+
it('an AUTHOR-MATCHED entry expires, so an identical send an HOUR later renders', () => {
|
|
435
|
+
const realNow = Date.now
|
|
436
|
+
try {
|
|
437
|
+
let now = 1_000_000
|
|
438
|
+
Date.now = () => now
|
|
439
|
+
const r = createChatStreamReducer({
|
|
440
|
+
transport: 'nats',
|
|
441
|
+
ownEchoIncludesAdmin: true,
|
|
442
|
+
selfUserId: 'tech-a',
|
|
443
|
+
})
|
|
444
|
+
// Tab A sends "ok" — the echo never lands (dropped frame / reconnect gap).
|
|
445
|
+
r.pushOptimisticSend('ok')
|
|
446
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
447
|
+
// An HOUR later the same user sends "ok" from tab B. The row is
|
|
448
|
+
// author-matched, but the stale entry must NOT eat it.
|
|
449
|
+
now += 60 * 60_000
|
|
450
|
+
r.apply({
|
|
451
|
+
type: 'participant',
|
|
452
|
+
kind: 'message-request',
|
|
453
|
+
text: 'ok',
|
|
454
|
+
ownerType: 'ADMIN',
|
|
455
|
+
userId: 'tech-a',
|
|
456
|
+
seq: 7,
|
|
457
|
+
})
|
|
458
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
459
|
+
} finally {
|
|
460
|
+
Date.now = realNow
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* REGRESSION (round 6): `OWN_ECHO_AUTHOR_TTL_MS` alone preserved TEN MINUTES
|
|
466
|
+
* of cross-tab message loss. A `turn-end` proves the server finished the
|
|
467
|
+
* send, so an echo that has not landed by then never will — the entry must
|
|
468
|
+
* be disarmed at that boundary, cutting the window to one turn.
|
|
469
|
+
*/
|
|
470
|
+
it('a turn-end disarms a stale echo, so a second-tab send minutes later RENDERS', () => {
|
|
471
|
+
const realNow = Date.now
|
|
472
|
+
try {
|
|
473
|
+
let now = 1_000_000
|
|
474
|
+
Date.now = () => now
|
|
475
|
+
const r = createChatStreamReducer({
|
|
476
|
+
transport: 'nats',
|
|
477
|
+
ownEchoIncludesAdmin: true,
|
|
478
|
+
selfUserId: 'tech-a',
|
|
479
|
+
})
|
|
480
|
+
// Tab A sends "ok" — the echo never lands.
|
|
481
|
+
r.pushOptimisticSend('ok')
|
|
482
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
483
|
+
// The turn runs and ends: the server is demonstrably done with that send.
|
|
484
|
+
r.apply({ type: 'turn-start', seq: 2 })
|
|
485
|
+
r.apply({ type: 'turn-end', seq: 3 })
|
|
486
|
+
// Five minutes later — well INSIDE the 10-minute backstop — the same
|
|
487
|
+
// user sends "ok" from tab B. It must render, not be swallowed.
|
|
488
|
+
now += 5 * 60_000
|
|
489
|
+
r.apply({
|
|
490
|
+
type: 'participant',
|
|
491
|
+
kind: 'message-request',
|
|
492
|
+
text: 'ok',
|
|
493
|
+
ownerType: 'ADMIN',
|
|
494
|
+
userId: 'tech-a',
|
|
495
|
+
seq: 7,
|
|
496
|
+
})
|
|
497
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
498
|
+
} finally {
|
|
499
|
+
Date.now = realNow
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
/** The echo entry is still consumed on the NORMAL path: the server's echo
|
|
504
|
+
* arrives before the turn ends, so `turn-end` must not break dedup. */
|
|
505
|
+
it('an echo landing BEFORE turn-end is still deduped', () => {
|
|
506
|
+
const r = createChatStreamReducer({
|
|
507
|
+
transport: 'nats',
|
|
508
|
+
ownEchoIncludesAdmin: true,
|
|
509
|
+
selfUserId: 'tech-a',
|
|
510
|
+
})
|
|
511
|
+
r.pushOptimisticSend('ok')
|
|
512
|
+
r.apply({
|
|
513
|
+
type: 'participant',
|
|
514
|
+
kind: 'message-request',
|
|
515
|
+
text: 'ok',
|
|
516
|
+
ownerType: 'ADMIN',
|
|
517
|
+
userId: 'tech-a',
|
|
518
|
+
seq: 2,
|
|
519
|
+
})
|
|
520
|
+
r.apply({ type: 'turn-start', seq: 3 })
|
|
521
|
+
r.apply({ type: 'turn-end', seq: 4 })
|
|
522
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* REGRESSION (round 7): the round-6 purge was UNCONDITIONAL, so a `turn-end`
|
|
527
|
+
* arriving between a send and its echo disarmed an entry that was never
|
|
528
|
+
* stale — the echo then rendered a SECOND user bubble. Reachable via a
|
|
529
|
+
* reconnect catch-up replay (a pre-disconnect MESSAGE_END delivered after
|
|
530
|
+
* the user has already sent) and via the approval-interrupt path (sending
|
|
531
|
+
* while an approval pends cancels the in-flight turn, so the interrupted
|
|
532
|
+
* turn's MESSAGE_END lands after the new send armed its entry). Only
|
|
533
|
+
* entries armed BEFORE the turn being ended may be purged.
|
|
534
|
+
*/
|
|
535
|
+
it('a turn-end arriving between a send and its echo does NOT duplicate the bubble', () => {
|
|
536
|
+
const r = createChatStreamReducer({
|
|
537
|
+
transport: 'nats',
|
|
538
|
+
ownEchoIncludesAdmin: true,
|
|
539
|
+
selfUserId: 'me',
|
|
540
|
+
})
|
|
541
|
+
r.pushOptimisticSend('hello world')
|
|
542
|
+
// A stale MESSAGE_END for a turn that started before this send (catch-up
|
|
543
|
+
// replay / approval interrupt) — no turn-start was ever observed here.
|
|
544
|
+
r.apply({ type: 'turn-end', seq: 10 })
|
|
545
|
+
r.apply({
|
|
546
|
+
type: 'participant',
|
|
547
|
+
kind: 'message-request',
|
|
548
|
+
text: 'hello world',
|
|
549
|
+
ownerType: 'ADMIN',
|
|
550
|
+
userId: 'me',
|
|
551
|
+
seq: 11,
|
|
552
|
+
})
|
|
553
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* DOCUMENTED LIMIT, not an aspiration: when a reconnect catch-up replay
|
|
558
|
+
* delivers BOTH a pre-disconnect `turn-start` and its `turn-end` after the
|
|
559
|
+
* user has sent, the replayed `turn-start` restamps the arrival-time
|
|
560
|
+
* boundary and the `turn-end` purges the still-live entry — the echo then
|
|
561
|
+
* renders a SECOND bubble. The normalized event union carries no event-own
|
|
562
|
+
* timestamp (only `seq`), so at this layer the interleaving is identical to
|
|
563
|
+
* "send queued mid-turn, own turn opened and closed before the echo", which
|
|
564
|
+
* the module deliberately resolves toward duplicating rather than risk
|
|
565
|
+
* swallowing a real message. Pins the behavior so a future transport-level
|
|
566
|
+
* fix (event-own time) has to update this test consciously.
|
|
567
|
+
*/
|
|
568
|
+
it('a replayed turn boundary after a send duplicates the bubble (known limit)', () => {
|
|
569
|
+
const realNow = Date.now
|
|
570
|
+
try {
|
|
571
|
+
let now = 1_000_000
|
|
572
|
+
Date.now = () => now
|
|
573
|
+
const r = createChatStreamReducer({
|
|
574
|
+
transport: 'nats',
|
|
575
|
+
ownEchoIncludesAdmin: true,
|
|
576
|
+
selfUserId: 'me',
|
|
577
|
+
})
|
|
578
|
+
r.pushOptimisticSend('hello')
|
|
579
|
+
now += 5 // the replay lands a few ms after the send
|
|
580
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
581
|
+
r.apply({ type: 'turn-end', seq: 2 })
|
|
582
|
+
r.apply({
|
|
583
|
+
type: 'participant',
|
|
584
|
+
kind: 'message-request',
|
|
585
|
+
text: 'hello',
|
|
586
|
+
ownerType: 'ADMIN',
|
|
587
|
+
userId: 'me',
|
|
588
|
+
seq: 3,
|
|
589
|
+
})
|
|
590
|
+
// Two bubbles: the optimistic one and the un-deduped echo.
|
|
591
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
592
|
+
} finally {
|
|
593
|
+
Date.now = realNow
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
/** Same shape, but the interrupted turn's `turn-start` WAS observed and the
|
|
598
|
+
* send happened after it — the entry belongs to the current turn and must
|
|
599
|
+
* survive its `turn-end`. */
|
|
600
|
+
it('an echo armed DURING the turn survives that turn-end', () => {
|
|
601
|
+
const realNow = Date.now
|
|
602
|
+
try {
|
|
603
|
+
let now = 1_000_000
|
|
604
|
+
Date.now = () => now
|
|
605
|
+
const r = createChatStreamReducer({
|
|
606
|
+
transport: 'nats',
|
|
607
|
+
ownEchoIncludesAdmin: true,
|
|
608
|
+
selfUserId: 'tech-a',
|
|
609
|
+
})
|
|
610
|
+
r.apply({ type: 'turn-start', seq: 2 })
|
|
611
|
+
now += 1 // the user types and sends while the turn streams
|
|
612
|
+
r.pushOptimisticSend('hello world')
|
|
613
|
+
r.apply({ type: 'turn-end', seq: 3 })
|
|
614
|
+
r.apply({
|
|
615
|
+
type: 'participant',
|
|
616
|
+
kind: 'message-request',
|
|
617
|
+
text: 'hello world',
|
|
618
|
+
ownerType: 'ADMIN',
|
|
619
|
+
userId: 'tech-a',
|
|
620
|
+
seq: 4,
|
|
621
|
+
})
|
|
622
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
623
|
+
} finally {
|
|
624
|
+
Date.now = realNow
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('an author-matched entry is still consumed just INSIDE the author TTL', () => {
|
|
629
|
+
const realNow = Date.now
|
|
630
|
+
try {
|
|
631
|
+
let now = 1_000_000
|
|
632
|
+
Date.now = () => now
|
|
633
|
+
const r = createChatStreamReducer({
|
|
634
|
+
transport: 'nats',
|
|
635
|
+
ownEchoIncludesAdmin: true,
|
|
636
|
+
selfUserId: 'tech-a',
|
|
637
|
+
})
|
|
638
|
+
r.pushOptimisticSend('ok')
|
|
639
|
+
now += OWN_ECHO_AUTHOR_TTL_MS - 1
|
|
640
|
+
r.apply({
|
|
641
|
+
type: 'participant',
|
|
642
|
+
kind: 'message-request',
|
|
643
|
+
text: 'ok',
|
|
644
|
+
ownerType: 'ADMIN',
|
|
645
|
+
userId: 'tech-a',
|
|
646
|
+
seq: 7,
|
|
647
|
+
})
|
|
648
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
649
|
+
} finally {
|
|
650
|
+
Date.now = realNow
|
|
651
|
+
}
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
/** A short-TTL (unattributed) lookup must not EVICT an entry a later
|
|
655
|
+
* author-matched row is still entitled to consume. */
|
|
656
|
+
it('an unattributed miss does not evict an entry the author path still owns', () => {
|
|
657
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
658
|
+
const realNow = Date.now
|
|
659
|
+
try {
|
|
660
|
+
let now = 1_000_000
|
|
661
|
+
Date.now = () => now
|
|
662
|
+
const r = createChatStreamReducer({
|
|
663
|
+
transport: 'nats',
|
|
664
|
+
ownEchoIncludesAdmin: true,
|
|
665
|
+
selfUserId: 'tech-a',
|
|
666
|
+
})
|
|
667
|
+
r.pushOptimisticSend('ok')
|
|
668
|
+
now += OWN_ECHO_TTL_MS + 1
|
|
669
|
+
// Unattributed row: outside the short TTL, so it renders (2 user rows)…
|
|
670
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'ok', ownerType: 'ADMIN', seq: 5 })
|
|
671
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
672
|
+
// …and our own late echo, declared ours, is still consumed.
|
|
673
|
+
r.apply({
|
|
674
|
+
type: 'participant',
|
|
675
|
+
kind: 'message-request',
|
|
676
|
+
text: 'ok',
|
|
677
|
+
ownerType: 'ADMIN',
|
|
678
|
+
userId: 'tech-a',
|
|
679
|
+
seq: 6,
|
|
680
|
+
})
|
|
681
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
682
|
+
} finally {
|
|
683
|
+
Date.now = realNow
|
|
684
|
+
warn.mockRestore()
|
|
685
|
+
}
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
it('an aged entry still expires on the UNATTRIBUTED path (no author to trust)', () => {
|
|
689
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
690
|
+
const realNow = Date.now
|
|
691
|
+
try {
|
|
692
|
+
let now = 1_000_000
|
|
693
|
+
Date.now = () => now
|
|
694
|
+
const r = createChatStreamReducer({
|
|
695
|
+
transport: 'nats',
|
|
696
|
+
ownEchoIncludesAdmin: true,
|
|
697
|
+
selfUserId: 'tech-a',
|
|
698
|
+
})
|
|
699
|
+
r.pushOptimisticSend('done')
|
|
700
|
+
now += OWN_ECHO_TTL_MS + 1
|
|
701
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
|
|
702
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
703
|
+
} finally {
|
|
704
|
+
Date.now = realNow
|
|
705
|
+
warn.mockRestore()
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('an echo entry expires, so an un-echoed send cannot arm a trap', () => {
|
|
710
|
+
const realNow = Date.now
|
|
711
|
+
try {
|
|
712
|
+
let now = 1_000_000
|
|
713
|
+
Date.now = () => now
|
|
714
|
+
const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
|
|
715
|
+
r.pushOptimisticSend('done')
|
|
716
|
+
// Our echo never lands. Much later, somebody else says the same thing.
|
|
717
|
+
now += OWN_ECHO_TTL_MS + 1
|
|
718
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
|
|
719
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
720
|
+
} finally {
|
|
721
|
+
Date.now = realNow
|
|
722
|
+
}
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('an echo INSIDE the TTL is still consumed', () => {
|
|
726
|
+
const realNow = Date.now
|
|
727
|
+
try {
|
|
728
|
+
let now = 1_000_000
|
|
729
|
+
Date.now = () => now
|
|
730
|
+
const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
|
|
731
|
+
r.pushOptimisticSend('done')
|
|
732
|
+
now += OWN_ECHO_TTL_MS - 1
|
|
733
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
|
|
734
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
735
|
+
} finally {
|
|
736
|
+
Date.now = realNow
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Parked-state round trip across an LRU eviction. The store captures these
|
|
743
|
+
* (`EvictedReducerState`); these tests pin the reducer half of the contract.
|
|
744
|
+
*/
|
|
745
|
+
describe('createChatStreamReducer — parked echo restore', () => {
|
|
746
|
+
const echoOptions = {
|
|
747
|
+
transport: 'nats',
|
|
748
|
+
ownEchoIncludesAdmin: true,
|
|
749
|
+
selfUserId: 'me',
|
|
750
|
+
} as const
|
|
751
|
+
|
|
752
|
+
it('restored pendingEchoes still consume the echo (no duplicate bubble)', () => {
|
|
753
|
+
const dropped = createChatStreamReducer(echoOptions)
|
|
754
|
+
dropped.pushOptimisticSend('hello world')
|
|
755
|
+
const parked = dropped.getPendingEchoes()
|
|
756
|
+
expect(parked).toHaveLength(1)
|
|
757
|
+
|
|
758
|
+
// The key was evicted between the send and its echo; the replacement
|
|
759
|
+
// reducer restores the armed entry alongside the refetched thread.
|
|
760
|
+
const recreated = createChatStreamReducer(echoOptions)
|
|
761
|
+
recreated.initializeWithState(
|
|
762
|
+
[{ id: 'u1', role: 'user', content: 'hello world' }],
|
|
763
|
+
{ pendingEchoes: parked },
|
|
764
|
+
)
|
|
765
|
+
recreated.apply({
|
|
766
|
+
type: 'participant',
|
|
767
|
+
kind: 'message-request',
|
|
768
|
+
text: 'hello world',
|
|
769
|
+
ownerType: 'ADMIN',
|
|
770
|
+
userId: 'me',
|
|
771
|
+
seq: 11,
|
|
772
|
+
})
|
|
773
|
+
expect(recreated.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('getPendingEchoes returns a SNAPSHOT, not the live internal array', () => {
|
|
777
|
+
const r = createChatStreamReducer(echoOptions)
|
|
778
|
+
r.pushOptimisticSend('hello world')
|
|
779
|
+
const parked = r.getPendingEchoes()
|
|
780
|
+
expect(parked).toHaveLength(1)
|
|
781
|
+
|
|
782
|
+
// Mutating the reducer after the snapshot must not change what the holder
|
|
783
|
+
// observes — the `readonly PendingEcho[]` return type has to be honest.
|
|
784
|
+
r.pushOptimisticSend('second')
|
|
785
|
+
expect(parked).toHaveLength(1)
|
|
786
|
+
expect(r.getPendingEchoes()).toHaveLength(2)
|
|
787
|
+
|
|
788
|
+
// ...and the consume path (which splices) must not empty it either.
|
|
789
|
+
r.apply({
|
|
790
|
+
type: 'participant',
|
|
791
|
+
kind: 'message-request',
|
|
792
|
+
text: 'hello world',
|
|
793
|
+
ownerType: 'ADMIN',
|
|
794
|
+
userId: 'me',
|
|
795
|
+
seq: 11,
|
|
796
|
+
})
|
|
797
|
+
expect(parked).toHaveLength(1)
|
|
798
|
+
expect(parked[0]?.text).toBe('hello world')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('drops parked entries already past the author TTL', () => {
|
|
802
|
+
const r = createChatStreamReducer(echoOptions)
|
|
803
|
+
r.initializeWithState(null, {
|
|
804
|
+
pendingEchoes: [{ text: 'ok', at: Date.now() - OWN_ECHO_AUTHOR_TTL_MS - 1 }],
|
|
805
|
+
})
|
|
806
|
+
expect(r.getPendingEchoes()).toHaveLength(0)
|
|
807
|
+
r.apply({
|
|
808
|
+
type: 'participant',
|
|
809
|
+
kind: 'message-request',
|
|
810
|
+
text: 'ok',
|
|
811
|
+
ownerType: 'ADMIN',
|
|
812
|
+
userId: 'me',
|
|
813
|
+
seq: 3,
|
|
814
|
+
})
|
|
815
|
+
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
816
|
+
})
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
describe('createChatStreamReducer — initializeWithState resumed gate', () => {
|
|
820
|
+
/** An eviction restore of a key that NEVER streamed (empty parked thread)
|
|
821
|
+
* must not claim "resumed": a later cold delta with no `turn-start` would
|
|
822
|
+
* take the append branch and never spawn the first assistant bubble. */
|
|
823
|
+
it('an EMPTY restore leaves the cold-start cumulative path armed', () => {
|
|
824
|
+
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
825
|
+
const r = createChatStreamReducer({
|
|
826
|
+
transport: 'nats',
|
|
827
|
+
onEffect: (e) => effects.push(e),
|
|
828
|
+
})
|
|
829
|
+
r.initializeWithState([], { lastAppliedSeq: 1 })
|
|
830
|
+
r.apply({ type: 'text-delta', text: 'hello', seq: 2 })
|
|
831
|
+
const updates = effects.filter((e) => e.name === 'onSegmentsUpdate')
|
|
832
|
+
const update = updates[updates.length - 1]
|
|
833
|
+
expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBeUndefined()
|
|
834
|
+
expect(r.state.messages.filter((m) => m.role === 'assistant')).toHaveLength(1)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('a NON-EMPTY restore is treated as resumed (post-stream deltas append)', () => {
|
|
838
|
+
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
839
|
+
const r = createChatStreamReducer({
|
|
840
|
+
transport: 'nats',
|
|
841
|
+
onEffect: (e) => effects.push(e),
|
|
842
|
+
})
|
|
843
|
+
r.initializeWithState([{ id: 'a1', role: 'assistant', content: 'prior', segments: [] }])
|
|
844
|
+
r.apply({ type: 'text-delta', text: 'more', seq: 2 })
|
|
845
|
+
const updates = effects.filter((e) => e.name === 'onSegmentsUpdate')
|
|
846
|
+
const update = updates[updates.length - 1]
|
|
847
|
+
expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBe(true)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('an explicit `resumed` overrides the derivation', () => {
|
|
851
|
+
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
852
|
+
const r = createChatStreamReducer({
|
|
853
|
+
transport: 'nats',
|
|
854
|
+
onEffect: (e) => effects.push(e),
|
|
855
|
+
})
|
|
856
|
+
r.initializeWithState([], { resumed: true })
|
|
857
|
+
r.apply({ type: 'text-delta', text: 'hello', seq: 2 })
|
|
858
|
+
const updates = effects.filter((e) => e.name === 'onSegmentsUpdate')
|
|
859
|
+
const update = updates[updates.length - 1]
|
|
860
|
+
expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBe(true)
|
|
861
|
+
})
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
describe('createChatStreamReducer — mergeApprovalStatuses precedence', () => {
|
|
865
|
+
it('stream-learned resolution beats a LAGGING persisted pending', () => {
|
|
866
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
867
|
+
r.setApprovalStatus('req-1', 'approved')
|
|
868
|
+
r.mergeApprovalStatuses({ 'req-1': 'pending' })
|
|
869
|
+
expect(r.state.approvalStatuses['req-1']).toBe('approved')
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('persisted resolution beats a STALE stream-learned pending (second tab)', () => {
|
|
873
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
874
|
+
// This tab saw the request; the operator approved it in another tab.
|
|
875
|
+
r.apply({
|
|
876
|
+
type: 'approval-request',
|
|
877
|
+
requestId: 'req-2',
|
|
878
|
+
command: 'rm -rf /tmp/x',
|
|
879
|
+
approvalType: 'CLIENT',
|
|
880
|
+
seq: 1,
|
|
881
|
+
})
|
|
882
|
+
expect(r.state.approvalStatuses['req-2']).toBeUndefined()
|
|
883
|
+
r.setApprovalStatus('req-2', 'pending')
|
|
884
|
+
r.mergeApprovalStatuses({ 'req-2': 'approved' })
|
|
885
|
+
expect(r.state.approvalStatuses['req-2']).toBe('approved')
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('fills unknown ids and is a no-op when nothing changes', () => {
|
|
889
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
890
|
+
r.setApprovalStatus('req-3', 'rejected')
|
|
891
|
+
r.mergeApprovalStatuses({ 'req-3': 'rejected', 'req-4': 'cancelled' })
|
|
892
|
+
expect(r.state.approvalStatuses).toEqual({ 'req-3': 'rejected', 'req-4': 'cancelled' })
|
|
893
|
+
const before = r.state.approvalStatuses
|
|
894
|
+
r.mergeApprovalStatuses({ 'req-3': 'pending', 'req-4': 'cancelled' })
|
|
895
|
+
expect(r.state.approvalStatuses).toBe(before)
|
|
896
|
+
})
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
// ─── GUIDE segments (#1583 threaded through the unified reader) ─────────────
|
|
900
|
+
//
|
|
901
|
+
// A `GUIDE` chunk is an APPEND-ONLY body stream like TEXT/THINKING, but it
|
|
902
|
+
// renders as a titled "OpenFrame Guide" card. It must therefore coalesce the
|
|
903
|
+
// same way (extend the trailing `guide` segment, never push a new one per
|
|
904
|
+
// chunk) and satisfy the same reducer invariants — seq idempotency,
|
|
905
|
+
// referential stability, and post-MESSAGE_END append mode.
|
|
906
|
+
|
|
907
|
+
describe('createChatStreamReducer — guide segments', () => {
|
|
908
|
+
it('coalesces consecutive guide deltas into ONE trailing guide segment', () => {
|
|
909
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
910
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
911
|
+
r.apply({ type: 'guide-delta', text: '## Enroll a device\n', seq: 2 })
|
|
912
|
+
r.apply({ type: 'guide-delta', text: '1. Open Settings', seq: 3 })
|
|
913
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
914
|
+
expect(last.segments).toEqual([
|
|
915
|
+
{ type: 'guide', text: '## Enroll a device\n1. Open Settings' },
|
|
916
|
+
])
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it('starts a NEW guide segment when a non-guide segment interrupts', () => {
|
|
920
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
921
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
922
|
+
r.apply({ type: 'guide-delta', text: 'step one', seq: 2 })
|
|
923
|
+
r.apply({ type: 'text-delta', text: 'aside', seq: 3 })
|
|
924
|
+
r.apply({ type: 'guide-delta', text: 'step two', seq: 4 })
|
|
925
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
926
|
+
expect(last.segments).toEqual([
|
|
927
|
+
{ type: 'guide', text: 'step one' },
|
|
928
|
+
{ type: 'text', text: 'aside' },
|
|
929
|
+
{ type: 'guide', text: 'step two' },
|
|
930
|
+
])
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
it('drops a redelivered guide delta via the shared seq gate (no double text)', () => {
|
|
934
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
935
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
936
|
+
r.apply({ type: 'guide-delta', text: 'a', seq: 2 })
|
|
937
|
+
r.apply({ type: 'guide-delta', text: 'b', seq: 3 })
|
|
938
|
+
r.apply({ type: 'guide-delta', text: 'b', seq: 3 })
|
|
939
|
+
r.apply({ type: 'guide-delta', text: 'a', seq: 2 })
|
|
940
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
941
|
+
expect(last.segments).toEqual([{ type: 'guide', text: 'ab' }])
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it('reclones ONLY the touched message — earlier rows keep identity', () => {
|
|
945
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
946
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'how do I enroll?', seq: 1 })
|
|
947
|
+
r.apply({ type: 'turn-start', seq: 2 })
|
|
948
|
+
r.apply({ type: 'guide-delta', text: 'first', seq: 3 })
|
|
949
|
+
const userRow = r.state.messages[0]
|
|
950
|
+
r.apply({ type: 'guide-delta', text: ' more', seq: 4 })
|
|
951
|
+
expect(r.state.messages[0]).toBe(userRow)
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
it('post-MESSAGE_END continuation emits a DELTA guide segment with append:true', () => {
|
|
955
|
+
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
956
|
+
const r = createChatStreamReducer({
|
|
957
|
+
transport: 'nats',
|
|
958
|
+
onEffect: (e) => effects.push(e),
|
|
959
|
+
})
|
|
960
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
961
|
+
r.apply({ type: 'guide-delta', text: 'in-stream ', seq: 2 })
|
|
962
|
+
r.apply({ type: 'turn-end', seq: 3 })
|
|
963
|
+
r.apply({ type: 'guide-delta', text: 'post-end', seq: 4 })
|
|
964
|
+
|
|
965
|
+
const appendEmits = effects.filter(
|
|
966
|
+
(e) =>
|
|
967
|
+
e.name === 'onSegmentsUpdate' &&
|
|
968
|
+
(e.args[1] as { append?: boolean } | undefined)?.append === true,
|
|
969
|
+
)
|
|
970
|
+
expect(appendEmits).toHaveLength(1)
|
|
971
|
+
expect(appendEmits[0].args[0]).toEqual([{ type: 'guide', text: 'post-end' }])
|
|
972
|
+
// …and the bubble carries ONE coalesced guide segment, not two.
|
|
973
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
974
|
+
expect(last.segments).toEqual([{ type: 'guide', text: 'in-stream post-end' }])
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* `setMessages` is a RAW setter and must not be used to hydrate history.
|
|
980
|
+
*
|
|
981
|
+
* It assigns the thread and nothing else — in particular it does not mark the
|
|
982
|
+
* reducer resumed, which is what makes a later chunk with no preceding
|
|
983
|
+
* `turn-start` take the APPEND branch. Hydrating with it leaves the reducer
|
|
984
|
+
* looking cold, so the first such chunk goes down the cumulative path and
|
|
985
|
+
* REPLACES the restored bubble's segments. When that bubble holds a pending
|
|
986
|
+
* approval card, the control the agent is blocked on disappears with it and
|
|
987
|
+
* there is no way back.
|
|
988
|
+
*
|
|
989
|
+
* `initializeWithState` derives `resumed` from the restored thread, which is
|
|
990
|
+
* why every hydration path must go through it (the SSE adapter always did; the
|
|
991
|
+
* NATS adapter used `setMessages` and hit exactly this).
|
|
992
|
+
*/
|
|
993
|
+
describe('createChatStreamReducer — history hydration must declare resumed', () => {
|
|
994
|
+
const hydrated = () => [
|
|
995
|
+
{
|
|
996
|
+
id: 'a1',
|
|
997
|
+
role: 'assistant' as const,
|
|
998
|
+
content: '',
|
|
999
|
+
segments: [
|
|
1000
|
+
{ type: 'text' as const, text: 'I need approval first.' },
|
|
1001
|
+
{
|
|
1002
|
+
type: 'approval_request' as const,
|
|
1003
|
+
data: { requestId: 'r1', command: 'systemctl restart nats' },
|
|
1004
|
+
status: 'pending' as const,
|
|
1005
|
+
},
|
|
1006
|
+
],
|
|
1007
|
+
},
|
|
1008
|
+
]
|
|
1009
|
+
|
|
1010
|
+
it('initializeWithState keeps a hydrated pending approval when a cold delta lands', () => {
|
|
1011
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1012
|
+
r.initializeWithState(hydrated())
|
|
1013
|
+
// No `turn-start`: the turn opened server-side before we subscribed.
|
|
1014
|
+
r.apply({ type: 'text-delta', text: 'Approved, restarting…', seq: 99 })
|
|
1015
|
+
|
|
1016
|
+
const segments = r.state.messages[r.state.messages.length - 1].segments ?? []
|
|
1017
|
+
expect(segments.filter((s) => s.type === 'approval_request')).toHaveLength(1)
|
|
1018
|
+
expect(segments.map((s) => s.type)).toEqual(['text', 'approval_request', 'text'])
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
it('CONTRAST: raw setMessages does NOT declare resumed, so the card is lost', () => {
|
|
1022
|
+
// Pinned deliberately. This is the footgun, not a desired behaviour: it
|
|
1023
|
+
// documents WHY hydration paths must use `initializeWithState`, and it
|
|
1024
|
+
// fails loudly if `setMessages` ever starts deriving `resumed` itself
|
|
1025
|
+
// (at which point the contrast above is redundant and can go).
|
|
1026
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1027
|
+
r.setMessages(hydrated())
|
|
1028
|
+
r.apply({ type: 'text-delta', text: 'Approved, restarting…', seq: 99 })
|
|
1029
|
+
|
|
1030
|
+
const segments = r.state.messages[r.state.messages.length - 1].segments ?? []
|
|
1031
|
+
expect(segments.filter((s) => s.type === 'approval_request')).toHaveLength(0)
|
|
1032
|
+
})
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* POST-`MESSAGE_END` ARRIVALS MUST NOT DESTROY THE COMPLETED BUBBLE.
|
|
1037
|
+
*
|
|
1038
|
+
* `turn-end` calls `accumulator.resetSegments()`, so once a turn has closed the
|
|
1039
|
+
* accumulator's "cumulative" array holds ONLY the segment just added. Any case
|
|
1040
|
+
* that emits that array in REPLACE mode therefore overwrites the finished
|
|
1041
|
+
* answer instead of extending it. `text-delta` and `tool-execution` both branch
|
|
1042
|
+
* on `isInStream` for exactly this reason; `approval-request` and `error` did
|
|
1043
|
+
* not, and post-END arrivals of both are routine — an approved command executes
|
|
1044
|
+
* between the approval bubble and the continuation stream, and the catchup
|
|
1045
|
+
* replay re-emits chunks that follow the last `MESSAGE_END` with no preceding
|
|
1046
|
+
* `turn-start`.
|
|
1047
|
+
*/
|
|
1048
|
+
describe('createChatStreamReducer — post-MESSAGE_END arrivals extend the bubble', () => {
|
|
1049
|
+
const trailingSegments = (r: ReturnType<typeof createChatStreamReducer>) =>
|
|
1050
|
+
r.state.messages[r.state.messages.length - 1]?.segments ?? []
|
|
1051
|
+
|
|
1052
|
+
const finishedTurn = () => {
|
|
1053
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1054
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1055
|
+
r.apply({ type: 'text-delta', text: 'Here is your answer.', seq: 2 })
|
|
1056
|
+
r.apply({ type: 'turn-end', seq: 3 })
|
|
1057
|
+
return r
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
it('an approval-request after turn-end APPENDS instead of replacing', () => {
|
|
1061
|
+
const r = finishedTurn()
|
|
1062
|
+
r.apply({
|
|
1063
|
+
type: 'approval-request',
|
|
1064
|
+
requestId: 'r1',
|
|
1065
|
+
approvalType: 'CLIENT',
|
|
1066
|
+
command: 'systemctl restart nats',
|
|
1067
|
+
seq: 4,
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
const segments = trailingSegments(r)
|
|
1071
|
+
expect(segments.map((s) => s.type)).toEqual(['text', 'approval_request'])
|
|
1072
|
+
expect(segments[0]).toEqual({ type: 'text', text: 'Here is your answer.' })
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
it('an error after turn-end APPENDS instead of replacing', () => {
|
|
1076
|
+
const r = finishedTurn()
|
|
1077
|
+
r.apply({ type: 'error', title: 'Tool failed', details: 'boom', seq: 4 })
|
|
1078
|
+
|
|
1079
|
+
const segments = trailingSegments(r)
|
|
1080
|
+
expect(segments.map((s) => s.type)).toEqual(['text', 'error'])
|
|
1081
|
+
expect(segments[0]).toEqual({ type: 'text', text: 'Here is your answer.' })
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
it('a COLD approval-request (no turn ever seen) still spawns the first bubble', () => {
|
|
1085
|
+
// The cold-start path must stay cumulative — otherwise the very first
|
|
1086
|
+
// approval of a session has no bubble to append into and renders nothing.
|
|
1087
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1088
|
+
r.apply({
|
|
1089
|
+
type: 'approval-request',
|
|
1090
|
+
requestId: 'r1',
|
|
1091
|
+
approvalType: 'CLIENT',
|
|
1092
|
+
command: 'ls',
|
|
1093
|
+
seq: 1,
|
|
1094
|
+
})
|
|
1095
|
+
expect(trailingSegments(r).map((s) => s.type)).toEqual(['approval_request'])
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('an IN-STREAM approval-request stays cumulative (accumulator owns the bubble)', () => {
|
|
1099
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1100
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1101
|
+
r.apply({ type: 'text-delta', text: 'Checking… ', seq: 2 })
|
|
1102
|
+
r.apply({
|
|
1103
|
+
type: 'approval-request',
|
|
1104
|
+
requestId: 'r1',
|
|
1105
|
+
approvalType: 'CLIENT',
|
|
1106
|
+
command: 'ls',
|
|
1107
|
+
seq: 3,
|
|
1108
|
+
})
|
|
1109
|
+
expect(trailingSegments(r).map((s) => s.type)).toEqual(['text', 'approval_request'])
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
it('a hydrated thread survives a post-END approval-request with no turn-start', () => {
|
|
1113
|
+
// The catchup-replay shape: history restored the finished turn, and the
|
|
1114
|
+
// replay begins AFTER the last MESSAGE_END — so no `turn-start` precedes the
|
|
1115
|
+
// card at all. `initializeWithState` with a non-empty thread is what marks
|
|
1116
|
+
// the reducer resumed, which is the signal that picks the append branch.
|
|
1117
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1118
|
+
r.initializeWithState([
|
|
1119
|
+
{
|
|
1120
|
+
id: 'a1',
|
|
1121
|
+
role: 'assistant',
|
|
1122
|
+
content: '',
|
|
1123
|
+
segments: [{ type: 'text', text: 'Long hydrated answer body.' }],
|
|
1124
|
+
},
|
|
1125
|
+
])
|
|
1126
|
+
r.apply({
|
|
1127
|
+
type: 'approval-request',
|
|
1128
|
+
requestId: 'r1',
|
|
1129
|
+
approvalType: 'CLIENT',
|
|
1130
|
+
command: 'ls',
|
|
1131
|
+
seq: 3,
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
// The card joins the hydrated bubble; the persisted answer is untouched.
|
|
1135
|
+
expect(trailingSegments(r).map((s) => s.type)).toEqual(['text', 'approval_request'])
|
|
1136
|
+
expect(trailingSegments(r)[0]).toEqual({
|
|
1137
|
+
type: 'text',
|
|
1138
|
+
text: 'Long hydrated answer body.',
|
|
1139
|
+
})
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('a turn-start after a hydrated thread opens a NEW bubble (no overwrite)', () => {
|
|
1143
|
+
// The complementary guarantee, so the fix above cannot be "improved" into
|
|
1144
|
+
// appending a fresh turn onto a completed one.
|
|
1145
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1146
|
+
r.initializeWithState([
|
|
1147
|
+
{ id: 'a1', role: 'assistant', content: '', segments: [{ type: 'text', text: 'Done.' }] },
|
|
1148
|
+
])
|
|
1149
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1150
|
+
r.apply({ type: 'text-delta', text: 'Next turn.', seq: 2 })
|
|
1151
|
+
|
|
1152
|
+
expect(r.state.messages).toHaveLength(2)
|
|
1153
|
+
expect(r.state.messages[0].segments).toEqual([{ type: 'text', text: 'Done.' }])
|
|
1154
|
+
expect(trailingSegments(r)).toEqual([{ type: 'text', text: 'Next turn.' }])
|
|
1155
|
+
})
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* `resetForDialogSwitch()` had NO direct coverage, while `reset()` is pinned
|
|
1160
|
+
* through the SSE adapter's goldens. That asymmetry matters because the two
|
|
1161
|
+
* functions clear overlapping-but-different field sets by hand: anything added
|
|
1162
|
+
* to the reducer and wired into only one of them becomes a silent cross-dialog
|
|
1163
|
+
* leak. These fixtures pin the per-dialog boundary field by field, INCLUDING
|
|
1164
|
+
* the two survivors that are deliberate — so a future "make them identical"
|
|
1165
|
+
* cleanup fails loudly instead of resurrecting a resolved approval as
|
|
1166
|
+
* actionable or un-suppressing a replaying catchup.
|
|
1167
|
+
*/
|
|
1168
|
+
describe('createChatStreamReducer — resetForDialogSwitch boundary', () => {
|
|
1169
|
+
it('clears the cumulative SSE turn text (no cross-dialog answer bleed)', () => {
|
|
1170
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
1171
|
+
r.beginSseSend({ text: 'question about dialog A' })
|
|
1172
|
+
r.apply({ type: 'text-delta', text: 'Answer for A.' })
|
|
1173
|
+
expect(r.state.messages[r.state.messages.length - 1].segments).toEqual([
|
|
1174
|
+
{ type: 'text', text: 'Answer for A.' },
|
|
1175
|
+
])
|
|
1176
|
+
|
|
1177
|
+
r.resetForDialogSwitch()
|
|
1178
|
+
// Dialog B is RESUMED mid-turn: history restores the open assistant bubble
|
|
1179
|
+
// and live deltas flow straight in, with no `beginSseSend` to zero the
|
|
1180
|
+
// cumulative buffer. `sseAppendText` only ever appends, so a surviving
|
|
1181
|
+
// buffer prepends the whole previous answer to B's first delta.
|
|
1182
|
+
r.initializeWithState([{ id: 'b1', role: 'assistant', content: '', segments: [] }])
|
|
1183
|
+
r.apply({ type: 'text-delta', text: 'Answer for B.' })
|
|
1184
|
+
|
|
1185
|
+
expect(r.state.messages[r.state.messages.length - 1].segments).toEqual([
|
|
1186
|
+
{ type: 'text', text: 'Answer for B.' },
|
|
1187
|
+
])
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
it('clears the per-send SSE maps and the send counter', () => {
|
|
1191
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
1192
|
+
r.beginSseSend({ text: 'q' })
|
|
1193
|
+
r.apply({ type: 'metadata', provider: 'anthropic', modelLabel: 'Opus' })
|
|
1194
|
+
expect(r.state.turnMeta.sendCount).toBe(1)
|
|
1195
|
+
expect(r.state.turnMeta.meta.get(0)?.modelLabel).toBe('Opus')
|
|
1196
|
+
|
|
1197
|
+
r.resetForDialogSwitch()
|
|
1198
|
+
|
|
1199
|
+
expect(r.state.turnMeta.sendCount).toBe(0)
|
|
1200
|
+
expect(r.state.turnMeta.meta.size).toBe(0)
|
|
1201
|
+
expect(r.state.turnMeta.sources.size).toBe(0)
|
|
1202
|
+
expect(r.state.turnMeta.refs.size).toBe(0)
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
it('SURVIVOR: approvalStatuses outlive the switch (request ids are global)', () => {
|
|
1206
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1207
|
+
r.setApprovalStatus('req-1', 'approved')
|
|
1208
|
+
r.resetForDialogSwitch()
|
|
1209
|
+
// A resolved approval whose APPROVAL_RESULT row is absent from the
|
|
1210
|
+
// refetched history page must NOT re-render as actionable.
|
|
1211
|
+
expect(r.state.approvalStatuses).toEqual({ 'req-1': 'approved' })
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
it('SURVIVOR: agent-busy suppression outlives the switch (in-flight refcount)', () => {
|
|
1215
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1216
|
+
// A catchup window opened before the switch; its `-1` is still pending.
|
|
1217
|
+
r.adjustAgentBusySuppression(1)
|
|
1218
|
+
r.resetForDialogSwitch()
|
|
1219
|
+
|
|
1220
|
+
// The dead tail replays EXECUTING_TOOL with no releasing MESSAGE_END. While
|
|
1221
|
+
// the window is open that must NOT engage the busy phase — zeroing the
|
|
1222
|
+
// count on a dialog switch is what would let an A → B → A re-entry lock the
|
|
1223
|
+
// composer on a still-replaying catchup.
|
|
1224
|
+
r.apply(executing('exec-a'))
|
|
1225
|
+
expect(r.state.streamingPhase).toBe('idle')
|
|
1226
|
+
|
|
1227
|
+
// Once the window's own `finally` releases it, busy engages again.
|
|
1228
|
+
r.adjustAgentBusySuppression(-1)
|
|
1229
|
+
r.apply(executing('exec-b'))
|
|
1230
|
+
expect(r.state.streamingPhase).toBe('thinking')
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
it('clears the seq gate so the new dialog’s own seq space starts fresh', () => {
|
|
1234
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1235
|
+
r.apply({ type: 'turn-start', seq: 500 })
|
|
1236
|
+
expect(r.getLastAppliedSeq()).toBe(500)
|
|
1237
|
+
|
|
1238
|
+
r.resetForDialogSwitch()
|
|
1239
|
+
|
|
1240
|
+
// Sequence spaces are per (dialogId, chatType): the next dialog's seq 1 is
|
|
1241
|
+
// NOT a redelivery and must be applied, not dropped by a carried-over gate.
|
|
1242
|
+
expect(r.getLastAppliedSeq()).toBe(Number.NEGATIVE_INFINITY)
|
|
1243
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1244
|
+
r.apply({ type: 'text-delta', text: 'fresh', seq: 2 })
|
|
1245
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
1246
|
+
expect(last.segments).toEqual([{ type: 'text', text: 'fresh' }])
|
|
1247
|
+
})
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* The reducer stamps the consumed chunk seq onto the rows it owns. This is not
|
|
1252
|
+
* bookkeeping — `mergeHistoryWithRealtime` decides per-message coverage with
|
|
1253
|
+
* it, and with the field missing it falls through to a wall-clock fallback that
|
|
1254
|
+
* drops live bubbles the backend has not finished persisting.
|
|
1255
|
+
*/
|
|
1256
|
+
describe('streamSeq stamping (history-merge coverage signal)', () => {
|
|
1257
|
+
it('stamps the trailing assistant with the seq that built it', () => {
|
|
1258
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1259
|
+
r.apply({ type: 'turn-start', seq: 10 })
|
|
1260
|
+
r.apply({ type: 'text-delta', text: 'hello', seq: 11 })
|
|
1261
|
+
|
|
1262
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
1263
|
+
expect(last.role).toBe('assistant')
|
|
1264
|
+
expect(last.streamSeq).toBe(11)
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
it('keeps the stamp MONOTONIC across a replay of older chunks', () => {
|
|
1268
|
+
// JetStream redelivers; coverage must never move backwards, or a replayed
|
|
1269
|
+
// early chunk would make an already-covered bubble look uncovered.
|
|
1270
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1271
|
+
r.apply({ type: 'turn-start', seq: 10 })
|
|
1272
|
+
r.apply({ type: 'text-delta', text: 'a', seq: 40 })
|
|
1273
|
+
r.apply({ type: 'text-delta', text: 'b', seq: 41 })
|
|
1274
|
+
|
|
1275
|
+
const before = r.state.messages[r.state.messages.length - 1].streamSeq
|
|
1276
|
+
// The gate drops it, but assert the stamp directly too.
|
|
1277
|
+
r.apply({ type: 'text-delta', text: 'stale', seq: 20 })
|
|
1278
|
+
|
|
1279
|
+
expect(before).toBe(41)
|
|
1280
|
+
expect(r.state.messages[r.state.messages.length - 1].streamSeq).toBe(41)
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
it('stamps participant rows too', () => {
|
|
1284
|
+
// `user-` synthetics need it to tell a REPLAYED echo (its row is already in
|
|
1285
|
+
// history) from a genuine new send that merely repeats an earlier text.
|
|
1286
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1287
|
+
r.apply({
|
|
1288
|
+
type: 'participant',
|
|
1289
|
+
kind: 'message-request',
|
|
1290
|
+
text: 'run it',
|
|
1291
|
+
ownerType: 'USER',
|
|
1292
|
+
seq: 7,
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
const row = r.state.messages[r.state.messages.length - 1]
|
|
1296
|
+
expect(row.role).toBe('user')
|
|
1297
|
+
expect(row.streamSeq).toBe(7)
|
|
1298
|
+
})
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* `agentBusy` — the restore-time counterpart of a live `onAgentBusy`. The
|
|
1303
|
+
* activity indicator is driven off the phase, and nothing on the wire brings it
|
|
1304
|
+
* back after a reload.
|
|
1305
|
+
*/
|
|
1306
|
+
describe('agentBusy on initializeWithState', () => {
|
|
1307
|
+
it('raises an idle reducer to thinking', () => {
|
|
1308
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1309
|
+
r.initializeWithState(null, { agentBusy: true })
|
|
1310
|
+
expect(r.state.streamingPhase).toBe('thinking')
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
it('leaves an open stream owning the phase', () => {
|
|
1314
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1315
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1316
|
+
expect(r.state.streamingPhase).toBe('streaming')
|
|
1317
|
+
|
|
1318
|
+
r.initializeWithState(null, { agentBusy: true })
|
|
1319
|
+
|
|
1320
|
+
// Only 'idle' upgrades — a downgrade to 'thinking' would report a live
|
|
1321
|
+
// stream as merely pending.
|
|
1322
|
+
expect(r.state.streamingPhase).toBe('streaming')
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
it('stays idle without the flag', () => {
|
|
1326
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1327
|
+
r.initializeWithState(null, { existingSegments: [{ type: 'text', text: 'done' }] })
|
|
1328
|
+
expect(r.state.streamingPhase).toBe('idle')
|
|
1329
|
+
})
|
|
1330
|
+
})
|