@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,1928 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createChatStreamReducer — THE master chat-stream reader (Phase 3 of the
|
|
3
|
+
* chat unification). One accumulation path for every event source:
|
|
4
|
+
*
|
|
5
|
+
* SSE bytes → createSseFrameDecoder ─┐
|
|
6
|
+
* NATS chunk → decodeNatsChunk ─┼→ ChatStreamEvent → reducer.apply()
|
|
7
|
+
* history → decodeHistoricalMessageData (per-item vocabulary) →
|
|
8
|
+
* envelope grouping → reducer.initializeWithState()
|
|
9
|
+
*
|
|
10
|
+
* The reducer absorbs, verbatim, the semantics that used to be spread over
|
|
11
|
+
* three layers:
|
|
12
|
+
* - `MessageSegmentAccumulator` stays as the internal per-turn segment
|
|
13
|
+
* kernel (instantiated here — its goldens remain valid);
|
|
14
|
+
* - the deleted `useRealtimeChunkProcessor`'s switch (in-stream vs post-END
|
|
15
|
+
* routing, agent-busy outside a message window, escalated approvals,
|
|
16
|
+
* compaction upsert, direct-mode barrier) — surfaced as
|
|
17
|
+
* `ChatReducerEffect`s for consumers that want the callback contract;
|
|
18
|
+
* - `useNatsChatAdapter`'s message-mutation callbacks (trailing-assistant
|
|
19
|
+
* replace/append, compaction upsert, cross-message tool merge, approval
|
|
20
|
+
* flip, participant dedup) — now the reducer's own state transitions via
|
|
21
|
+
* `./message-mutations`;
|
|
22
|
+
* - `useSseChatAdapter`/`useChat`'s SSE turn kernel (cumulative text
|
|
23
|
+
* replace, front-inserted thinking, approval card, `decision_resolved`
|
|
24
|
+
* receipt + cardRef stamping, sendIdx-keyed sources/refs/meta maps).
|
|
25
|
+
*
|
|
26
|
+
* Idempotency:
|
|
27
|
+
* - events whose `seq` ≤ the last applied seq are dropped (per instance);
|
|
28
|
+
* - participant rows additionally dedup via a one-shot optimistic-echo
|
|
29
|
+
* list and a short same-author content window (seq-less transports);
|
|
30
|
+
* - value-level no-op merges return prior references (see
|
|
31
|
+
* `message-mutations.ts`).
|
|
32
|
+
*
|
|
33
|
+
* TRANSPORT ORDERING ASSUMPTION (the seq gate, see `apply()`): every
|
|
34
|
+
* transport feeding this reducer is AT-LEAST-ONCE and IN-ORDER. JetStream
|
|
35
|
+
* redelivers (hence the gate) but never reorders within a stream, and the
|
|
36
|
+
* catchup back-fill replays a monotonically increasing range. So `seq <=
|
|
37
|
+
* lastAppliedSeq` means "already applied", and dropping is correct.
|
|
38
|
+
* A genuinely out-of-order arrival (seq 5 then 4) would be discarded
|
|
39
|
+
* PERMANENTLY — accepted deliberately: no transport here reorders, and an
|
|
40
|
+
* out-of-order buffer would add latency + unbounded state to guard against
|
|
41
|
+
* a case that cannot occur. If a reordering transport is ever added, this
|
|
42
|
+
* gate is the place that must change (do not paper over it downstream).
|
|
43
|
+
*
|
|
44
|
+
* `resolvePendingApprovalForExecution` (the accumulator's implicit
|
|
45
|
+
* approve-on-execution) is reachable ONLY on `transport: 'nats'` — the SSE
|
|
46
|
+
* kernel never routes tool executions through the accumulator.
|
|
47
|
+
*
|
|
48
|
+
* PURE + framework-free: no React, no timers, no network. Side effects the
|
|
49
|
+
* UI needs (approve/reject POSTs) enter as opaque callbacks stamped onto
|
|
50
|
+
* approval segments.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
MessageSegmentAccumulator,
|
|
55
|
+
createMessageSegmentAccumulator,
|
|
56
|
+
type AccumulatorCallbacks,
|
|
57
|
+
} from '../utils/message-segment-accumulator'
|
|
58
|
+
import { getCommandText } from '../utils/tool-call-helpers'
|
|
59
|
+
import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
|
|
60
|
+
import { escapeThinkingTags } from '../../../chat-protocol/decode'
|
|
61
|
+
import { buildChatRefKey } from '../types/chat.types'
|
|
62
|
+
import type {
|
|
63
|
+
ApprovalRequestSegment,
|
|
64
|
+
ChatApprovalStatus,
|
|
65
|
+
MessageSegment,
|
|
66
|
+
PendingToolCallData,
|
|
67
|
+
SegmentsUpdateMetadata,
|
|
68
|
+
ToolExecutionSegment,
|
|
69
|
+
ExecutingToolState,
|
|
70
|
+
} from '../types'
|
|
71
|
+
import type { PendingApproval } from '../types/processing.types'
|
|
72
|
+
import type {
|
|
73
|
+
DialogTokenUsage,
|
|
74
|
+
StreamingPhase,
|
|
75
|
+
UnifiedChatMessage,
|
|
76
|
+
UnifiedUsageBreakdown,
|
|
77
|
+
} from '../types/unified-chat-state.types'
|
|
78
|
+
import {
|
|
79
|
+
CHAT_OWNER_ADMIN,
|
|
80
|
+
type ChatStreamEvent,
|
|
81
|
+
type ApprovalResolvedEvent,
|
|
82
|
+
type ChatMetadataEvent,
|
|
83
|
+
type ParticipantEvent,
|
|
84
|
+
type UsageEvent,
|
|
85
|
+
} from '../../../chat-protocol/events'
|
|
86
|
+
import type { ChatRef } from '../chat-ref.types'
|
|
87
|
+
import {
|
|
88
|
+
CONTENT_DEDUP_WINDOW,
|
|
89
|
+
SYSTEM_DEDUP_WINDOW,
|
|
90
|
+
appendToTrailingAssistant,
|
|
91
|
+
applyToolExecutionToMessages,
|
|
92
|
+
hasRecentMessage,
|
|
93
|
+
mergeToolExecutionIfPresent,
|
|
94
|
+
nextId,
|
|
95
|
+
projectApprovalResolutionToMessages,
|
|
96
|
+
updateTrailingAssistant,
|
|
97
|
+
upsertTrailingCompaction,
|
|
98
|
+
} from './message-mutations'
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Public types
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
/** Per-turn metadata extracted from the streamed metadata/usage frames
|
|
105
|
+
* (SSE transport). Canonical home — `use-sse-chat-adapter` re-exports it. */
|
|
106
|
+
export interface ChatTurnMeta {
|
|
107
|
+
provider: string | null
|
|
108
|
+
modelLabel: string | null
|
|
109
|
+
contextWindowMaxTokens: number | null
|
|
110
|
+
/** Input tokens (from usage:start). Includes cached tokens. */
|
|
111
|
+
inputTokens: number | null
|
|
112
|
+
/** Output tokens (from the trailing usage frame). */
|
|
113
|
+
outputTokens: number | null
|
|
114
|
+
/** Cache hit % (read / total-input × 100). Only known after stream end. */
|
|
115
|
+
cacheHitRatePct: number | null
|
|
116
|
+
/** Cross-call usage breakdown extracted from the trailing usage frame. */
|
|
117
|
+
breakdown: UnifiedUsageBreakdown | null
|
|
118
|
+
/** Per-message viewport-positioning hint. */
|
|
119
|
+
scrollAnchor: ScrollAnchor | null
|
|
120
|
+
routedComplexity: string | null
|
|
121
|
+
routedThinkingBudget: number | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Single source of truth for a fresh `ChatTurnMeta` row. */
|
|
125
|
+
export function createEmptyTurnMeta(): ChatTurnMeta {
|
|
126
|
+
return {
|
|
127
|
+
provider: null,
|
|
128
|
+
modelLabel: null,
|
|
129
|
+
contextWindowMaxTokens: null,
|
|
130
|
+
inputTokens: null,
|
|
131
|
+
outputTokens: null,
|
|
132
|
+
cacheHitRatePct: null,
|
|
133
|
+
breakdown: null,
|
|
134
|
+
scrollAnchor: null,
|
|
135
|
+
routedComplexity: null,
|
|
136
|
+
routedThinkingBudget: null,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** SSE per-send maps, keyed by the send counter (`sendIdx`). Each user send
|
|
141
|
+
* produces ONE server-side refs/sources entry but can fan out to MULTIPLE
|
|
142
|
+
* assistant messages client-side — the adapter maps every following
|
|
143
|
+
* assistant message back to its send's entry. */
|
|
144
|
+
export interface ChatTurnMetaState {
|
|
145
|
+
meta: Map<number, ChatTurnMeta>
|
|
146
|
+
sources: Map<number, unknown[]>
|
|
147
|
+
refs: Map<number, Record<string, ChatRef>>
|
|
148
|
+
sendCount: number
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ChatReducerState {
|
|
152
|
+
messages: UnifiedChatMessage[]
|
|
153
|
+
streamingPhase: StreamingPhase
|
|
154
|
+
turnMeta: ChatTurnMetaState
|
|
155
|
+
dialogTokenUsage?: DialogTokenUsage | null
|
|
156
|
+
liveModel?: {
|
|
157
|
+
provider: string | null
|
|
158
|
+
modelLabel: string | null
|
|
159
|
+
contextWindowMaxTokens: number | null
|
|
160
|
+
} | null
|
|
161
|
+
approvalStatuses: Record<string, ChatApprovalStatus>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Escalated-approval bookkeeping entry (mirrors the legacy processor). */
|
|
165
|
+
export interface EscalatedApprovalData {
|
|
166
|
+
command: string
|
|
167
|
+
explanation?: string
|
|
168
|
+
approvalType: string
|
|
169
|
+
toolCalls?: PendingToolCallData[]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Callback-visible effect produced by `apply()`. Names + args mirror the
|
|
174
|
+
* legacy `RealtimeChunkCallbacks` contract 1:1 — the contract the deleted
|
|
175
|
+
* `useRealtimeChunkProcessor` wrapper exposed, now pinned directly by
|
|
176
|
+
* `__tests__/chat-stream-reducer-golden.test.ts`.
|
|
177
|
+
*
|
|
178
|
+
* `segments-after-approval-result` is the one conditional emission: the
|
|
179
|
+
* legacy processor emitted a cumulative `onSegmentsUpdate` for a
|
|
180
|
+
* non-escalated APPROVAL_RESULT only when the consumer had NOT wired
|
|
181
|
+
* `onApprovalResolved` — the effect sink resolves that at dispatch time.
|
|
182
|
+
*/
|
|
183
|
+
export interface ChatReducerEffect {
|
|
184
|
+
name:
|
|
185
|
+
| 'onStreamStart'
|
|
186
|
+
| 'onStreamEnd'
|
|
187
|
+
| 'onMetadata'
|
|
188
|
+
| 'onSegmentsUpdate'
|
|
189
|
+
| 'onError'
|
|
190
|
+
| 'onUserMessage'
|
|
191
|
+
| 'onTokenUsage'
|
|
192
|
+
| 'onDirectMessage'
|
|
193
|
+
| 'onSystemMessage'
|
|
194
|
+
| 'onEscalatedApproval'
|
|
195
|
+
| 'onEscalatedApprovalResult'
|
|
196
|
+
| 'onApprovalResolved'
|
|
197
|
+
| 'onToolExecuted'
|
|
198
|
+
| 'onAgentBusy'
|
|
199
|
+
| 'onDialogClosed'
|
|
200
|
+
| 'segments-after-approval-result'
|
|
201
|
+
args: unknown[]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ChatStreamReducerOptions {
|
|
205
|
+
/** Which transport's turn kernel drives `apply()`. Default `'nats'`. */
|
|
206
|
+
transport?: 'sse' | 'nats'
|
|
207
|
+
/** Seed for the approval-status map (request-id → status). */
|
|
208
|
+
approvalStatuses?: Record<string, ChatApprovalStatus>
|
|
209
|
+
/** Batch APPROVAL_REQUESTs render as one card (default true) or unfold. */
|
|
210
|
+
batchApprovalsEnabled?: boolean
|
|
211
|
+
/** Approval types displayed inline; others escalate. Default ['CLIENT']. */
|
|
212
|
+
displayApprovalTypes?: string[]
|
|
213
|
+
/** Opaque approve/reject handlers stamped onto approval segments. */
|
|
214
|
+
callbacks?: AccumulatorCallbacks
|
|
215
|
+
/** Engage the direct-mode barrier optimistically (host-known takeover). */
|
|
216
|
+
isDirectMode?: boolean
|
|
217
|
+
/**
|
|
218
|
+
* Legacy-processor parity knob: post-MESSAGE_END tool chunks route
|
|
219
|
+
* cross-message (`onToolExecuted` effect + `applyToolExecutionToMessages`)
|
|
220
|
+
* only when the consumer wired the cross-message updater; otherwise they
|
|
221
|
+
* fall through the accumulator like the pre-callback code path. Default
|
|
222
|
+
* true (both first-party adapters wire it).
|
|
223
|
+
*/
|
|
224
|
+
crossMessageToolRouting?: boolean
|
|
225
|
+
/** Effect sink for callback-contract consumers (compat wrapper). */
|
|
226
|
+
onEffect?: (effect: ChatReducerEffect) => void
|
|
227
|
+
/** Notified after every state mutation (the dialog store wires this). */
|
|
228
|
+
onChange?: () => void
|
|
229
|
+
/**
|
|
230
|
+
* Whether an ADMIN-authored `MESSAGE_REQUEST` may be consumed as OUR OWN
|
|
231
|
+
* optimistic echo (see `pushOptimisticSend`). Default `false`.
|
|
232
|
+
*
|
|
233
|
+
* Who the local operator is decides this, and it differs per host:
|
|
234
|
+
* - Hub website chat / ticket CLIENT side: the local user is NEVER the
|
|
235
|
+
* admin, so an ADMIN-authored inbound row is a technician's reply and
|
|
236
|
+
* must ALWAYS render. Consuming it as an echo would silently delete a
|
|
237
|
+
* real message whenever its text happened to match ours. → `false`.
|
|
238
|
+
* - OpenFrame product app (Mingo + ticket ADMIN side): the operator IS
|
|
239
|
+
* the admin, so their own sends echo back as ADMIN and MUST be
|
|
240
|
+
* deduped, or every message renders twice. → `true`.
|
|
241
|
+
*
|
|
242
|
+
* Only ever applies to text the host itself registered via
|
|
243
|
+
* `pushOptimisticSend`, so enabling it cannot drop a message the host
|
|
244
|
+
* did not just send.
|
|
245
|
+
*/
|
|
246
|
+
ownEchoIncludesAdmin?: boolean
|
|
247
|
+
/**
|
|
248
|
+
* The LOCAL user's id, either as a value or as a GETTER resolved at event
|
|
249
|
+
* time. When it resolves to a value, an inbound `MESSAGE_REQUEST` that
|
|
250
|
+
* DECLARES a different author (`event.userId !== selfUserId`) may not be
|
|
251
|
+
* consumed as our own optimistic echo.
|
|
252
|
+
*
|
|
253
|
+
* Without it the echo list matches on RAW TEXT alone, which on a shared
|
|
254
|
+
* ADMIN side (two technicians in one ticket, `ownEchoIncludesAdmin: true`)
|
|
255
|
+
* can silently delete a colleague's message: if OUR send never echoes back,
|
|
256
|
+
* its entry stays armed and the next identical text from anyone — canned
|
|
257
|
+
* replies like "ok" / "done" / "on it" make this routine — is swallowed.
|
|
258
|
+
*
|
|
259
|
+
* The guard FAILS OPEN, deliberately: a row that carries NO `userId` is not
|
|
260
|
+
* "someone else's", it is UNATTRIBUTED, and rejecting it would disable
|
|
261
|
+
* dedup entirely (→ every send rendered twice) on any transport whose
|
|
262
|
+
* decoder does not surface the author id. Such a row falls back to the
|
|
263
|
+
* text + `OWN_ECHO_TTL_MS` behaviour instead, and a one-shot `console.warn`
|
|
264
|
+
* makes the missing id observable.
|
|
265
|
+
*
|
|
266
|
+
* Pass a FUNCTION whenever the id can change or arrive late (auth
|
|
267
|
+
* rehydration, logout / login-as-another-user without a reload): reducers
|
|
268
|
+
* are retained for the store's lifetime, so a value captured at creation
|
|
269
|
+
* time can go stale and silently disable the guard.
|
|
270
|
+
*/
|
|
271
|
+
selfUserId?: string | (() => string | undefined)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* How long an un-consumed optimistic-echo entry stays armed **on the
|
|
276
|
+
* UNATTRIBUTED path**. Long enough to cover any realistic send → server-echo
|
|
277
|
+
* round trip, short enough that a send whose echo never lands cannot swallow
|
|
278
|
+
* an unrelated identical message later in the conversation.
|
|
279
|
+
*
|
|
280
|
+
* A row that is DECLARED ours (`selfUserId` set and `event.userId ===
|
|
281
|
+
* selfUserId`) gets the LONGER `OWN_ECHO_AUTHOR_TTL_MS` instead — see there.
|
|
282
|
+
*/
|
|
283
|
+
export const OWN_ECHO_TTL_MS = 30_000
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* How long an un-consumed entry stays armed on the AUTHOR-MATCHED path
|
|
287
|
+
* (`selfUserId` set, inbound row declares the same author).
|
|
288
|
+
*
|
|
289
|
+
* The author check rules out CROSS-technician theft, but NOT the same user on
|
|
290
|
+
* a second tab or device: a row THIS tab did not originate looks exactly like
|
|
291
|
+
* an author-matched echo. So the entry still needs an upper bound — without
|
|
292
|
+
* one, a send whose echo never lands (dropped frame, backend text
|
|
293
|
+
* normalization, reconnect gap) leaves the entry armed for the reducer's
|
|
294
|
+
* whole lifetime, and the next identical text from the same user on ANOTHER
|
|
295
|
+
* tab is silently swallowed. With `MAX_PENDING_ECHOES` armed slots and canned
|
|
296
|
+
* replies ("ok", "done", "on it") being exactly the recurring strings, that is
|
|
297
|
+
* a routine message-LOSS bug, strictly worse than the duplicate row it trades
|
|
298
|
+
* against.
|
|
299
|
+
*
|
|
300
|
+
* Ten minutes is the bound: generously longer than the slow echo the author
|
|
301
|
+
* path exists to protect (a JetStream catch-up replay after a network gap
|
|
302
|
+
* arrives long after the send, and its rows carry `seq`, so the seq-less
|
|
303
|
+
* content-dedup fallback cannot rescue it), and far shorter than the
|
|
304
|
+
* "same text again an hour later" window in which a stale entry does damage.
|
|
305
|
+
*
|
|
306
|
+
* It is a BACKSTOP, not the usual bound: `turn-end` disarms entries much
|
|
307
|
+
* sooner (see `purgeEchoesAtTurnEnd`). The ten minutes only ever apply to a
|
|
308
|
+
* dialog that never reports a turn boundary at all.
|
|
309
|
+
*/
|
|
310
|
+
export const OWN_ECHO_AUTHOR_TTL_MS = 10 * 60_000
|
|
311
|
+
|
|
312
|
+
/** Cap on simultaneously-armed echo entries (a backend that never echoes must
|
|
313
|
+
* not grow the list without bound). */
|
|
314
|
+
const MAX_PENDING_ECHOES = 5
|
|
315
|
+
|
|
316
|
+
/** One armed optimistic-echo entry: the sent text and the wall-clock ms it was
|
|
317
|
+
* armed at. Exported because the LRU-eviction round trip PARKS these (see
|
|
318
|
+
* `getPendingEchoes` / `InitializeExtras.pendingEchoes`). */
|
|
319
|
+
export interface PendingEcho {
|
|
320
|
+
text: string
|
|
321
|
+
at: number
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface InitializeExtras {
|
|
325
|
+
existingSegments?: MessageSegment[]
|
|
326
|
+
pendingApprovals?: Map<string, PendingApproval>
|
|
327
|
+
executingTools?: Map<string, ExecutingToolState>
|
|
328
|
+
escalatedApprovals?: Map<string, EscalatedApprovalData>
|
|
329
|
+
/**
|
|
330
|
+
* Approval statuses PARKED from a previous instance of this key (LRU
|
|
331
|
+
* eviction hands them to `onEvict`). Merged with the same state-monotonic
|
|
332
|
+
* precedence as `mergeApprovalStatuses` — a resolved approval must not come
|
|
333
|
+
* back as actionable, which is exactly why `resetForDialogSwitch` preserves
|
|
334
|
+
* this map rather than clearing it.
|
|
335
|
+
*/
|
|
336
|
+
approvalStatuses?: Record<string, ChatApprovalStatus>
|
|
337
|
+
/**
|
|
338
|
+
* Seq cursor PARKED from a previous instance of this key. A recreated
|
|
339
|
+
* reducer starts at `-Infinity`, so a host that replays from its own cursor
|
|
340
|
+
* would re-apply already-applied events; restoring the parked value keeps
|
|
341
|
+
* `apply()`'s idempotency gate intact across an eviction. Only ever moves
|
|
342
|
+
* the gate FORWARD (a lower value is ignored).
|
|
343
|
+
*/
|
|
344
|
+
lastAppliedSeq?: number
|
|
345
|
+
/**
|
|
346
|
+
* Armed optimistic-echo entries PARKED from a previous instance of this key.
|
|
347
|
+
* Without them, a key LRU-evicted between `pushOptimisticSend` and its
|
|
348
|
+
* `MESSAGE_REQUEST` echo leaves the replacement reducer with nothing armed,
|
|
349
|
+
* so the echo renders a DUPLICATE user bubble. Entries already past
|
|
350
|
+
* `OWN_ECHO_AUTHOR_TTL_MS` are dropped on restore (an expired entry could
|
|
351
|
+
* only swallow an unrelated identical message), and the list is capped at
|
|
352
|
+
* `MAX_PENDING_ECHOES` exactly as the live path is.
|
|
353
|
+
*/
|
|
354
|
+
pendingEchoes?: readonly PendingEcho[]
|
|
355
|
+
/**
|
|
356
|
+
* Whether the restored thread is a RESUMED dialog (a `MESSAGE_START` already
|
|
357
|
+
* fired server-side), which makes post-stream continuation chunks append
|
|
358
|
+
* into the existing bubble instead of taking the cold-start cumulative path.
|
|
359
|
+
* Defaults to "the restored thread is non-empty" — an eviction restore of a
|
|
360
|
+
* key that never streamed must NOT claim it did, or a cold text-delta with
|
|
361
|
+
* no preceding `turn-start` appends into a bubble that was never spawned.
|
|
362
|
+
* Pass explicitly only to override that derivation.
|
|
363
|
+
*/
|
|
364
|
+
resumed?: boolean
|
|
365
|
+
/**
|
|
366
|
+
* Whether the restored tail means the AGENT IS STILL WORKING — as opposed to
|
|
367
|
+
* blocked on the user. Raises an `idle` reducer to `thinking` on restore
|
|
368
|
+
* (same rule as a live `onAgentBusy`: an open stream keeps the phase), which
|
|
369
|
+
* is what puts the activity indicator back.
|
|
370
|
+
*
|
|
371
|
+
* Nothing on the wire restores this: the phase machine is driven by events,
|
|
372
|
+
* and a run's EXECUTING chunk is long past by the time a reload happens while
|
|
373
|
+
* its EXECUTED one may be minutes away. Derived once by
|
|
374
|
+
* `extractIncompleteMessageState` so every host agrees on the distinction —
|
|
375
|
+
* a PENDING approval is the opposite state and must NOT spin.
|
|
376
|
+
*/
|
|
377
|
+
agentBusy?: boolean
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface BeginSseSendOptions {
|
|
381
|
+
text: string
|
|
382
|
+
hidden?: boolean
|
|
383
|
+
userName?: string
|
|
384
|
+
assistantName?: string
|
|
385
|
+
assistantAvatar?: string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// =============================================================================
|
|
389
|
+
// Reducer
|
|
390
|
+
// =============================================================================
|
|
391
|
+
|
|
392
|
+
export interface ChatStreamReducer {
|
|
393
|
+
/** Apply one normalized stream event. */
|
|
394
|
+
apply(event: ChatStreamEvent): void
|
|
395
|
+
/** Current immutable state snapshot (stable identity between mutations). */
|
|
396
|
+
readonly state: ChatReducerState
|
|
397
|
+
/** Full reset — thread, per-turn kernel, dedup sets, seq gate, maps. */
|
|
398
|
+
reset(): void
|
|
399
|
+
/**
|
|
400
|
+
* Seed the thread (history hydration / persisted-state rehydration) and
|
|
401
|
+
* optionally the per-turn kernel + escalated approvals (resume of an
|
|
402
|
+
* incomplete turn). `messages: null` keeps the current thread. Escalated
|
|
403
|
+
* entries are re-surfaced via `onEscalatedApproval` effects. Marks the
|
|
404
|
+
* instance as having streamed so continuation chunks append.
|
|
405
|
+
*/
|
|
406
|
+
initializeWithState(messages: UnifiedChatMessage[] | null, extras?: InitializeExtras): void
|
|
407
|
+
|
|
408
|
+
// ── Thread commands (adapter-driven, not wire events) ──────────────────
|
|
409
|
+
setMessages(messages: UnifiedChatMessage[]): void
|
|
410
|
+
prependMessages(messages: UnifiedChatMessage[]): void
|
|
411
|
+
/** Optimistic local send: user bubble (+ echo record) + assistant placeholder. */
|
|
412
|
+
pushOptimisticSend(text: string, hidden?: boolean): void
|
|
413
|
+
/** Wipe thread + per-turn kernel, keep approval statuses (legacy clear). */
|
|
414
|
+
clearThread(): void
|
|
415
|
+
/** Per-dialog reset (keeps approval statuses — request-ids are global). */
|
|
416
|
+
resetForDialogSwitch(): void
|
|
417
|
+
|
|
418
|
+
// ── SSE turn commands ───────────────────────────────────────────────────
|
|
419
|
+
beginSseSend(options: BeginSseSendOptions): void
|
|
420
|
+
endSseTurn(): void
|
|
421
|
+
failSseTurn(errorMessage: string): void
|
|
422
|
+
seedSseMaps(seed: {
|
|
423
|
+
sources?: Array<[number, unknown[]]>
|
|
424
|
+
refs?: Array<[number, Record<string, ChatRef>]>
|
|
425
|
+
sendCount?: number
|
|
426
|
+
}): void
|
|
427
|
+
|
|
428
|
+
// ── Phase / status commands ─────────────────────────────────────────────
|
|
429
|
+
setPhase(phase: StreamingPhase): void
|
|
430
|
+
setApprovalStatus(requestId: string, status: ChatApprovalStatus | null): void
|
|
431
|
+
/**
|
|
432
|
+
* CANONICAL merge of a PERSISTED status map (host-side store, history
|
|
433
|
+
* hydration, dialog-switch top-up) into this reducer's map.
|
|
434
|
+
*
|
|
435
|
+
* Precedence is STATE-MONOTONIC, not source-based, because either side can
|
|
436
|
+
* be the stale one:
|
|
437
|
+
* - persisted lags (a snapshot taken before the stream resolved the
|
|
438
|
+
* request) → the stream's resolved status must win, or a just-approved
|
|
439
|
+
* card would be downgraded back to `pending` and re-arm its buttons;
|
|
440
|
+
* - the STREAM lags (this tab saw the request, the operator resolved it in
|
|
441
|
+
* a SECOND TAB, and the persisted map is the one carrying the truth) →
|
|
442
|
+
* the persisted resolution must win, or the card stays actionable and
|
|
443
|
+
* invites a second approve on an already-resolved request.
|
|
444
|
+
*
|
|
445
|
+
* With statuses being exactly `pending | approved | rejected | cancelled`,
|
|
446
|
+
* "resolved beats pending" is a total order that gets both directions
|
|
447
|
+
* right: take the persisted value when ours is `pending` and theirs isn't;
|
|
448
|
+
* keep ours otherwise. Persisted entries still fill in every request-id the
|
|
449
|
+
* stream hasn't seen (other dialogs, pre-session history).
|
|
450
|
+
*/
|
|
451
|
+
mergeApprovalStatuses(persisted: Record<string, ChatApprovalStatus>): void
|
|
452
|
+
setDirectMode(isDirectMode: boolean): void
|
|
453
|
+
setDialogTokenUsage(usage: DialogTokenUsage | null): void
|
|
454
|
+
/** +1/-1 windows during catchup replay (suppresses agent-busy locks). */
|
|
455
|
+
adjustAgentBusySuppression(delta: number): void
|
|
456
|
+
armAdoptTrailingAssistant(value: boolean): void
|
|
457
|
+
|
|
458
|
+
// ── Cross-side projections (dialog-store fan-out) ───────────────────────
|
|
459
|
+
projectApprovalResolution(
|
|
460
|
+
requestId: string,
|
|
461
|
+
status: ChatApprovalStatus,
|
|
462
|
+
resolvedByName?: string | null,
|
|
463
|
+
): void
|
|
464
|
+
projectToolExecution(segment: ToolExecutionSegment): void
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The seq gate's current value (`-Infinity` before anything seq-carrying was
|
|
468
|
+
* applied). Not part of `state` — it is bookkeeping, not render input — but
|
|
469
|
+
* a host that PARKS a reducer (LRU eviction, dialog swap) needs it to
|
|
470
|
+
* restore idempotency on the recreated instance via
|
|
471
|
+
* `initializeWithState(messages, { lastAppliedSeq })`.
|
|
472
|
+
*/
|
|
473
|
+
getLastAppliedSeq(): number
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Currently-armed optimistic-echo entries. Same rationale as
|
|
477
|
+
* `getLastAppliedSeq`: not render input, but a host that PARKS a reducer
|
|
478
|
+
* needs them to restore echo dedup on the recreated instance via
|
|
479
|
+
* `initializeWithState(messages, { pendingEchoes })` — otherwise an echo
|
|
480
|
+
* in flight across the eviction renders a duplicate user bubble.
|
|
481
|
+
*/
|
|
482
|
+
getPendingEchoes(): readonly PendingEcho[]
|
|
483
|
+
|
|
484
|
+
// ── Legacy processor surface (compat wrapper) ───────────────────────────
|
|
485
|
+
getSegments(): MessageSegment[]
|
|
486
|
+
updateApprovalStatus(
|
|
487
|
+
requestId: string,
|
|
488
|
+
status: ChatApprovalStatus,
|
|
489
|
+
resolvedByName?: string | null,
|
|
490
|
+
): MessageSegment[]
|
|
491
|
+
getPendingEscalated(): Map<string, EscalatedApprovalData>
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Actions allowed through once the dialog is in direct mode. Everything else
|
|
495
|
+
// is an AI-assistant event and gets dropped — an allowlist so any future
|
|
496
|
+
// assistant event is blocked by default.
|
|
497
|
+
function isDirectModeAllowed(event: ChatStreamEvent): boolean {
|
|
498
|
+
return event.type === 'participant' || event.type === 'dialog-closed'
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function createChatStreamReducer(
|
|
502
|
+
options: ChatStreamReducerOptions = {},
|
|
503
|
+
): ChatStreamReducer {
|
|
504
|
+
const {
|
|
505
|
+
transport = 'nats',
|
|
506
|
+
batchApprovalsEnabled = true,
|
|
507
|
+
displayApprovalTypes = ['CLIENT'],
|
|
508
|
+
callbacks = {},
|
|
509
|
+
crossMessageToolRouting = true,
|
|
510
|
+
onEffect,
|
|
511
|
+
onChange,
|
|
512
|
+
} = options
|
|
513
|
+
|
|
514
|
+
// ── Mutable internals ──────────────────────────────────────────────────
|
|
515
|
+
let messages: UnifiedChatMessage[] = []
|
|
516
|
+
let streamingPhase: StreamingPhase = 'idle'
|
|
517
|
+
let dialogTokenUsage: DialogTokenUsage | null = null
|
|
518
|
+
let liveModel: ChatReducerState['liveModel'] = null
|
|
519
|
+
let approvalStatuses: Record<string, ChatApprovalStatus> = {
|
|
520
|
+
...(options.approvalStatuses ?? {}),
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const accumulator: MessageSegmentAccumulator = createMessageSegmentAccumulator(callbacks)
|
|
524
|
+
|
|
525
|
+
// Stream-window flags (chunk-processor parity).
|
|
526
|
+
let isInStream = false
|
|
527
|
+
let hasEverStreamed = false
|
|
528
|
+
|
|
529
|
+
// Direct-mode barrier.
|
|
530
|
+
let directModeFlag = options.isDirectMode ?? false
|
|
531
|
+
let sawDirectMessage = false
|
|
532
|
+
let directTeardownFired = false
|
|
533
|
+
|
|
534
|
+
// Escalated approvals (single or batch).
|
|
535
|
+
const pendingEscalated = new Map<string, EscalatedApprovalData>()
|
|
536
|
+
|
|
537
|
+
// Idempotency. The per-participant seen-seq set that used to live here was
|
|
538
|
+
// REMOVED as redundant: `apply()`'s global gate already drops any event
|
|
539
|
+
// whose seq was applied before, so a seq-carrying participant row can never
|
|
540
|
+
// reach the handlers twice.
|
|
541
|
+
let lastAppliedSeq = Number.NEGATIVE_INFINITY
|
|
542
|
+
let pendingEchoTexts: PendingEcho[] = []
|
|
543
|
+
/** `Date.now()` of the last observed `turn-start`; 0 = none seen yet.
|
|
544
|
+
* Bounds `purgeEchoesAtTurnEnd` — see there. */
|
|
545
|
+
let turnStartedAt = 0
|
|
546
|
+
|
|
547
|
+
/** Consume a one-shot optimistic-echo entry for `text`. Returns true when
|
|
548
|
+
* this inbound row IS our own echo and must not render.
|
|
549
|
+
*
|
|
550
|
+
* `authorMatched` widens the expiry window (`OWN_ECHO_AUTHOR_TTL_MS` vs
|
|
551
|
+
* `OWN_ECHO_TTL_MS`) — it does NOT remove it. The author check rules out a
|
|
552
|
+
* colleague's message, not the same user's second tab, so an entry whose
|
|
553
|
+
* echo never landed must still age out or it eventually swallows a real
|
|
554
|
+
* message. `MAX_PENDING_ECHOES` bounds growth on both paths. */
|
|
555
|
+
function consumeOwnEcho(text: string, authorMatched = false): boolean {
|
|
556
|
+
if (pendingEchoTexts.length === 0) return false
|
|
557
|
+
const now = Date.now()
|
|
558
|
+
// Purge at the WIDEST bound only: a short-TTL (unattributed) lookup must
|
|
559
|
+
// not evict an entry that a later author-matched row is still entitled to
|
|
560
|
+
// consume. The per-path window is applied at match time instead.
|
|
561
|
+
pendingEchoTexts = pendingEchoTexts.filter((e) => now - e.at < OWN_ECHO_AUTHOR_TTL_MS)
|
|
562
|
+
const ttl = authorMatched ? OWN_ECHO_AUTHOR_TTL_MS : OWN_ECHO_TTL_MS
|
|
563
|
+
const idx = pendingEchoTexts.findIndex((e) => e.text === text && now - e.at < ttl)
|
|
564
|
+
if (idx === -1) return false
|
|
565
|
+
pendingEchoTexts.splice(idx, 1)
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Disarm every echo entry that was armed BEFORE the turn this `turn-end`
|
|
570
|
+
* closes (i.e. before the last observed `turn-start`).
|
|
571
|
+
*
|
|
572
|
+
* A `turn-end` is proof the server finished processing everything it had
|
|
573
|
+
* accepted up to that point, so an echo for a send made BEFORE the turn
|
|
574
|
+
* opened that has not landed by now never will (dropped frame, backend
|
|
575
|
+
* text normalization, reconnect gap). Keeping the entry armed for the full
|
|
576
|
+
* `OWN_ECHO_AUTHOR_TTL_MS` preserves up to ten minutes in which the same
|
|
577
|
+
* user's identical send from a SECOND TAB is silently swallowed — the
|
|
578
|
+
* message-LOSS failure this module ranks strictly worse than the duplicate
|
|
579
|
+
* row it trades against. Draining at the turn boundary cuts that window
|
|
580
|
+
* from ten minutes to one turn.
|
|
581
|
+
*
|
|
582
|
+
* The purge is SELECTIVE (`at > turnStartedAt`) rather than unconditional:
|
|
583
|
+
* an entry armed DURING the turn being ended is not stale by the argument
|
|
584
|
+
* above — its echo is still in flight — and dropping it duplicates the
|
|
585
|
+
* user's bubble. Such entries survive to their normal author TTL; entries
|
|
586
|
+
* from earlier turns still drain here.
|
|
587
|
+
*
|
|
588
|
+
* WHAT THIS COVERS, EXACTLY. `turnStartedAt` is the ARRIVAL wall-clock of
|
|
589
|
+
* the last `turn-start` this reducer saw, NOT the event's own time: the
|
|
590
|
+
* normalized event union carries no timestamp. `ChatStreamEventBase` has a
|
|
591
|
+
* single optional field, `seq` (JetStream `streamSeq`, lifted by
|
|
592
|
+
* `decodeNatsChunk`); the SSE decoder emits a bare `{ type: 'turn-start' }`
|
|
593
|
+
* with no envelope at all. The boundary is therefore an ARRIVAL order, and
|
|
594
|
+
* only these cases are actually handled:
|
|
595
|
+
*
|
|
596
|
+
* - COVERED — a `turn-end` with NO observed `turn-start` purges nothing
|
|
597
|
+
* (`turnStartedAt` starts at 0; there is no boundary to be stale
|
|
598
|
+
* relative to). This is the approval-interrupt shape as the adapter
|
|
599
|
+
* drives it — sending while an approval pends cancels the in-flight
|
|
600
|
+
* turn, so the interrupted turn's `turn-end` lands after the new send
|
|
601
|
+
* armed its entry — and any stale `turn-end` on a fresh reducer.
|
|
602
|
+
* - COVERED — `turn-start` observed BEFORE the send, `turn-end` after: the
|
|
603
|
+
* entry was armed past the boundary and survives.
|
|
604
|
+
* - NOT COVERED — a reconnect catch-up replay that delivers BOTH a
|
|
605
|
+
* pre-disconnect `turn-start` and its `turn-end` after the user has
|
|
606
|
+
* already sent. The replayed `turn-start` restamps `turnStartedAt` to
|
|
607
|
+
* now, so the following `turn-end` purges a still-live entry and the
|
|
608
|
+
* echo renders a SECOND bubble. This is not fixable at this layer: with
|
|
609
|
+
* no event-own timestamp, that interleaving is indistinguishable from
|
|
610
|
+
* "the send was queued mid-turn, and this reducer's own turn opened and
|
|
611
|
+
* closed before the echo landed" — a case the module deliberately
|
|
612
|
+
* resolves toward the duplicate row rather than risk swallowing a real
|
|
613
|
+
* message. `seq` does not disambiguate it either: a replay whose seqs
|
|
614
|
+
* were already applied is dropped by `apply()`'s idempotency gate before
|
|
615
|
+
* reaching here, so any replay that DOES reach here carries
|
|
616
|
+
* strictly-increasing unseen seqs — exactly what a live turn carries.
|
|
617
|
+
* Pinned by the "a replayed turn boundary after a send duplicates the
|
|
618
|
+
* bubble" test. Fix it by giving the transport an event-own time, not by
|
|
619
|
+
* widening the purge here.
|
|
620
|
+
*
|
|
621
|
+
* `OWN_ECHO_AUTHOR_TTL_MS` remains the backstop for dialogs that never
|
|
622
|
+
* report a turn boundary at all.
|
|
623
|
+
*
|
|
624
|
+
* Same-millisecond ties break toward PURGING (`>`, not `>=`): a send and
|
|
625
|
+
* the turn it opened share a timestamp, and that entry IS the pre-turn
|
|
626
|
+
* one. A send made DURING a turn is a human action a turn-start later, so
|
|
627
|
+
* it never ties in practice. */
|
|
628
|
+
function purgeEchoesAtTurnEnd(): void {
|
|
629
|
+
if (pendingEchoTexts.length === 0) return
|
|
630
|
+
if (turnStartedAt === 0) return
|
|
631
|
+
pendingEchoTexts = pendingEchoTexts.filter((e) => e.at > turnStartedAt)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Resolve `selfUserId` AT EVENT TIME — a getter lets the host track auth
|
|
635
|
+
* rehydration / user switches on a reducer that outlives both. */
|
|
636
|
+
function resolveSelfUserId(): string | undefined {
|
|
637
|
+
const self = options.selfUserId
|
|
638
|
+
return typeof self === 'function' ? self() : self
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** One-shot: the host configured `selfUserId` but inbound rows carry none,
|
|
642
|
+
* so the author guard is inert and dedup silently degrades to text+TTL. */
|
|
643
|
+
let warnedIdLessAuthor = false
|
|
644
|
+
function warnIdLessAuthor(): void {
|
|
645
|
+
if (warnedIdLessAuthor) return
|
|
646
|
+
warnedIdLessAuthor = true
|
|
647
|
+
console.warn(
|
|
648
|
+
'[chat] selfUserId is configured but an inbound MESSAGE_REQUEST carries no userId — ' +
|
|
649
|
+
'the author echo guard is inert and dedup falls back to text + TTL matching. ' +
|
|
650
|
+
'Check the transport decoder surfaces the author id (the ticket wire model ' +
|
|
651
|
+
'declares it at `owner.userId`) and that it is in the SAME id space as selfUserId.',
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Adapter-armed flags.
|
|
656
|
+
let suppressAgentBusy = 0
|
|
657
|
+
let adoptTrailingAssistant = false
|
|
658
|
+
|
|
659
|
+
// SSE per-send maps + per-turn kernel.
|
|
660
|
+
const metaMap = new Map<number, ChatTurnMeta>()
|
|
661
|
+
const sourcesMap = new Map<number, unknown[]>()
|
|
662
|
+
const refsMap = new Map<number, Record<string, ChatRef>>()
|
|
663
|
+
let sendCount = 0
|
|
664
|
+
let sseCurrentText = ''
|
|
665
|
+
|
|
666
|
+
// ── Snapshot cache ─────────────────────────────────────────────────────
|
|
667
|
+
let stateCache: ChatReducerState | null = null
|
|
668
|
+
function invalidate(): void {
|
|
669
|
+
stateCache = null
|
|
670
|
+
onChange?.()
|
|
671
|
+
}
|
|
672
|
+
function getState(): ChatReducerState {
|
|
673
|
+
if (!stateCache) {
|
|
674
|
+
stateCache = {
|
|
675
|
+
messages,
|
|
676
|
+
streamingPhase,
|
|
677
|
+
turnMeta: { meta: metaMap, sources: sourcesMap, refs: refsMap, sendCount },
|
|
678
|
+
dialogTokenUsage,
|
|
679
|
+
liveModel,
|
|
680
|
+
approvalStatuses,
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return stateCache
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function emit(name: ChatReducerEffect['name'], ...args: unknown[]): void {
|
|
687
|
+
onEffect?.({ name, args })
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function setMessagesInternal(next: UnifiedChatMessage[]): void {
|
|
691
|
+
if (next !== messages) {
|
|
692
|
+
messages = next
|
|
693
|
+
invalidate()
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function setPhaseInternal(phase: StreamingPhase): void {
|
|
698
|
+
if (streamingPhase !== phase) {
|
|
699
|
+
streamingPhase = phase
|
|
700
|
+
invalidate()
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** onAgentBusy state semantics: only 'idle' upgrades to 'thinking'; an
|
|
705
|
+
* open stream keeps ownership. Suppressed during catchup replay. */
|
|
706
|
+
function agentBusyState(): void {
|
|
707
|
+
if (suppressAgentBusy > 0) return
|
|
708
|
+
if (streamingPhase === 'idle') setPhaseInternal('thinking')
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function mirrorApprovalStatus(requestId: string, status: ChatApprovalStatus): void {
|
|
712
|
+
if (approvalStatuses[requestId] === status) return
|
|
713
|
+
approvalStatuses = { ...approvalStatuses, [requestId]: status }
|
|
714
|
+
invalidate()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
// NATS kernel — the absorbed chunk-processor switch + adapter callbacks body
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Stamp the highest consumed chunk seq onto the trailing assistant row.
|
|
723
|
+
*
|
|
724
|
+
* This is what `mergeHistoryWithRealtime` decides coverage with, per message.
|
|
725
|
+
* Leave it off and the merge has no per-row signal, so it falls through to
|
|
726
|
+
* the wall-clock fallback — "this synthetic predates the fetch instant, so
|
|
727
|
+
* history must contain it" — which is false for any turn the backend has not
|
|
728
|
+
* finished persisting. A history refetch mid-stream (the one a reconnect
|
|
729
|
+
* fires) then drops every live assistant bubble except the one that happens
|
|
730
|
+
* to be `streamingMessageId`, leaving the thread as just the persisted user
|
|
731
|
+
* prompt. The host store used to stamp this from the `onSegmentsUpdate`
|
|
732
|
+
* metadata; now the reducer owns the thread, so it owns the stamp.
|
|
733
|
+
*
|
|
734
|
+
* Monotonic: replay can re-apply an older seq, and coverage must never go
|
|
735
|
+
* backwards.
|
|
736
|
+
*/
|
|
737
|
+
function stampTrailingAssistantSeq(
|
|
738
|
+
next: UnifiedChatMessage[],
|
|
739
|
+
seq: number | undefined,
|
|
740
|
+
): UnifiedChatMessage[] {
|
|
741
|
+
if (typeof seq !== 'number') return next
|
|
742
|
+
const last = next[next.length - 1]
|
|
743
|
+
if (!last || last.role !== 'assistant') return next
|
|
744
|
+
if (typeof last.streamSeq === 'number' && last.streamSeq >= seq) return next
|
|
745
|
+
return [...next.slice(0, -1), { ...last, streamSeq: seq }]
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function applySegmentsToState(segments: MessageSegment[], meta?: SegmentsUpdateMetadata): void {
|
|
749
|
+
const seq = meta?.streamSeq
|
|
750
|
+
// Standalone compaction updates carry the accumulator's cumulative
|
|
751
|
+
// array — apply only the compaction segment (upsert) or interleaved
|
|
752
|
+
// continuation text would duplicate.
|
|
753
|
+
if (meta?.append && meta.isCompacting) {
|
|
754
|
+
setMessagesInternal(stampTrailingAssistantSeq(upsertTrailingCompaction(messages, segments), seq))
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
// Post-MESSAGE_END continuation fragments append into the existing
|
|
758
|
+
// bubble; replacing would wipe the completed reply.
|
|
759
|
+
if (meta?.append) {
|
|
760
|
+
setMessagesInternal(stampTrailingAssistantSeq(appendToTrailingAssistant(messages, segments), seq))
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
setMessagesInternal(stampTrailingAssistantSeq(updateTrailingAssistant(messages, segments), seq))
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function applyStreamStartToState(): void {
|
|
767
|
+
setPhaseInternal('streaming')
|
|
768
|
+
// A new stream must never overwrite a COMPLETED trailing assistant
|
|
769
|
+
// bubble (observer tab / second device / post-approval continuation
|
|
770
|
+
// turn). Open a fresh bubble unless the trailing assistant is empty
|
|
771
|
+
// (our own optimistic placeholder) or is the incomplete history tail
|
|
772
|
+
// the catchup replay is legitimately re-streaming (adopt-once flag).
|
|
773
|
+
const adoptTail = adoptTrailingAssistant
|
|
774
|
+
adoptTrailingAssistant = false
|
|
775
|
+
const last = messages[messages.length - 1]
|
|
776
|
+
if (!last || last.role !== 'assistant' || adoptTail) return
|
|
777
|
+
const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== ''
|
|
778
|
+
if (!hasContent) return
|
|
779
|
+
setMessagesInternal([
|
|
780
|
+
...messages,
|
|
781
|
+
{ id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
|
|
782
|
+
])
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** MESSAGE_REQUEST echo — a user message from THIS or another session.
|
|
786
|
+
* Dedup layers in priority order: seq identity → one-shot optimistic-echo
|
|
787
|
+
* consumption → same-author content window (seq-less transports only). */
|
|
788
|
+
function applyUserMessageToState(ev: ParticipantEvent): void {
|
|
789
|
+
const text = ev.text
|
|
790
|
+
if (!text) return
|
|
791
|
+
const seq = ev.seq
|
|
792
|
+
// ADMIN rows are only echo-consumable when the host declares that its
|
|
793
|
+
// operator IS the admin (see `ownEchoIncludesAdmin`). Otherwise an
|
|
794
|
+
// ADMIN row is someone else's message and must always render.
|
|
795
|
+
const adminEchoAllowed =
|
|
796
|
+
ev.ownerType !== CHAT_OWNER_ADMIN || options.ownEchoIncludesAdmin === true
|
|
797
|
+
// AUTHOR guard: with `selfUserId` configured, a row that DECLARES a
|
|
798
|
+
// DIFFERENT author may not consume an echo entry. Two technicians on the
|
|
799
|
+
// same ADMIN side both author ADMIN rows, so text alone cannot tell "my
|
|
800
|
+
// echo" from "their message".
|
|
801
|
+
//
|
|
802
|
+
// It fails OPEN on an id-less row: the decoder may not surface the author
|
|
803
|
+
// (the ticket wire model declares it at `owner.userId`, and the app's id
|
|
804
|
+
// may live in another id space entirely), and treating "unattributed" as
|
|
805
|
+
// "not mine" would disable dedup outright — every send rendered TWICE,
|
|
806
|
+
// strictly worse than the bug the guard fixes. Unattributed rows fall back
|
|
807
|
+
// to the text + TTL behaviour, with a one-shot warning so the
|
|
808
|
+
// misconfiguration is observable.
|
|
809
|
+
const selfUserId = resolveSelfUserId()
|
|
810
|
+
const authorDeclared = typeof ev.userId === 'string' && ev.userId !== ''
|
|
811
|
+
if (selfUserId !== undefined && !authorDeclared) warnIdLessAuthor()
|
|
812
|
+
const authorMatched = selfUserId !== undefined && authorDeclared && ev.userId === selfUserId
|
|
813
|
+
const authorEchoAllowed = selfUserId === undefined || !authorDeclared || authorMatched
|
|
814
|
+
if (adminEchoAllowed && authorEchoAllowed && consumeOwnEcho(text, authorMatched)) return
|
|
815
|
+
const authorType = ev.ownerType === CHAT_OWNER_ADMIN ? ('admin' as const) : ('user' as const)
|
|
816
|
+
if (
|
|
817
|
+
typeof seq !== 'number' &&
|
|
818
|
+
hasRecentMessage(
|
|
819
|
+
messages,
|
|
820
|
+
(m) => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,
|
|
821
|
+
CONTENT_DEDUP_WINDOW,
|
|
822
|
+
)
|
|
823
|
+
) {
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
setMessagesInternal([
|
|
827
|
+
...messages,
|
|
828
|
+
{
|
|
829
|
+
id: nextId('user'),
|
|
830
|
+
role: 'user',
|
|
831
|
+
content: text,
|
|
832
|
+
// Coverage signal for the history merge — see `streamSeq` on
|
|
833
|
+
// `UnifiedChatMessage`. For `user-` rows it is also what tells a
|
|
834
|
+
// REPLAYED echo (history already has its row) apart from a genuine new
|
|
835
|
+
// send that merely repeats an earlier text.
|
|
836
|
+
...(typeof seq === 'number' ? { streamSeq: seq } : {}),
|
|
837
|
+
...(ev.displayName ? { name: ev.displayName } : {}),
|
|
838
|
+
authorType,
|
|
839
|
+
...(ev.contextItems && ev.contextItems.length > 0
|
|
840
|
+
? { contextItems: ev.contextItems }
|
|
841
|
+
: {}),
|
|
842
|
+
},
|
|
843
|
+
])
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Technician / admin direct message into the dialog. */
|
|
847
|
+
function applyDirectMessageToState(ev: ParticipantEvent): void {
|
|
848
|
+
const text = ev.text
|
|
849
|
+
if (!text) return
|
|
850
|
+
const seq = ev.seq
|
|
851
|
+
// Seq-less fallback matches only prior ADMIN-authored twins — a client's
|
|
852
|
+
// identical text must never suppress the technician's.
|
|
853
|
+
if (
|
|
854
|
+
typeof seq !== 'number' &&
|
|
855
|
+
hasRecentMessage(
|
|
856
|
+
messages,
|
|
857
|
+
(m) => m.role === 'user' && m.authorType === 'admin' && m.content === text,
|
|
858
|
+
CONTENT_DEDUP_WINDOW,
|
|
859
|
+
)
|
|
860
|
+
) {
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
setMessagesInternal([
|
|
864
|
+
...messages,
|
|
865
|
+
{
|
|
866
|
+
id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
867
|
+
role: 'user',
|
|
868
|
+
content: text,
|
|
869
|
+
...(typeof seq === 'number' ? { streamSeq: seq } : {}),
|
|
870
|
+
name: ev.displayName ?? 'Admin',
|
|
871
|
+
authorType: 'admin',
|
|
872
|
+
},
|
|
873
|
+
])
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** System notice — rendered as a name-only row (same shape the history
|
|
877
|
+
* processor produces via its standalone-SYSTEM path). */
|
|
878
|
+
function applySystemMessageToState(ev: ParticipantEvent): void {
|
|
879
|
+
const text = ev.text
|
|
880
|
+
if (!text) return
|
|
881
|
+
const seq = ev.seq
|
|
882
|
+
if (
|
|
883
|
+
typeof seq !== 'number' &&
|
|
884
|
+
hasRecentMessage(
|
|
885
|
+
messages,
|
|
886
|
+
(m) => m.authorType === 'system' && m.name === text,
|
|
887
|
+
SYSTEM_DEDUP_WINDOW,
|
|
888
|
+
)
|
|
889
|
+
) {
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
setMessagesInternal([
|
|
893
|
+
...messages,
|
|
894
|
+
{
|
|
895
|
+
id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
896
|
+
role: 'user',
|
|
897
|
+
content: '',
|
|
898
|
+
...(typeof seq === 'number' ? { streamSeq: seq } : {}),
|
|
899
|
+
name: text,
|
|
900
|
+
authorType: 'system',
|
|
901
|
+
},
|
|
902
|
+
])
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function applyNats(event: ChatStreamEvent): void {
|
|
906
|
+
const seq = event.seq
|
|
907
|
+
const withSeqMeta = (meta?: SegmentsUpdateMetadata): SegmentsUpdateMetadata | undefined =>
|
|
908
|
+
seq != null ? { ...meta, streamSeq: seq } : meta
|
|
909
|
+
const emitSegments = (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) =>
|
|
910
|
+
emit('onSegmentsUpdate', segments, withSeqMeta(meta))
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Emit + apply an accumulator result, choosing REPLACE vs APPEND the same
|
|
914
|
+
* way `text-delta` does — and it must be chosen, not defaulted.
|
|
915
|
+
*
|
|
916
|
+
* `turn-end` calls `accumulator.resetSegments()`, so after a turn closes the
|
|
917
|
+
* accumulator's cumulative array holds ONLY what was added since. Applying
|
|
918
|
+
* that array in replace mode overwrites the finished answer: a
|
|
919
|
+
* post-MESSAGE_END approval card or error used to reduce a completed bubble
|
|
920
|
+
* to `[approval_request]` / `[error]` and the reply was gone. Post-END
|
|
921
|
+
* arrivals are routine (an approved command executes between the approval
|
|
922
|
+
* bubble and the continuation stream, and catchup replays everything after
|
|
923
|
+
* the last MESSAGE_END with no preceding `turn-start`).
|
|
924
|
+
*
|
|
925
|
+
* `before` is the accumulator's segment count taken BEFORE the add, so the
|
|
926
|
+
* appended slice is exactly the new segments — never a hand-rebuilt copy
|
|
927
|
+
* that could drift from the accumulator's shape (which stamps
|
|
928
|
+
* `onApprove`/`onReject` handlers), and never a positional guess. An add
|
|
929
|
+
* that UPSERTS in place (a redelivered card) yields an empty slice and is
|
|
930
|
+
* correctly a no-op here: status flips travel through `approval-resolved`.
|
|
931
|
+
*
|
|
932
|
+
* Cold start (`!hasEverStreamed`) stays cumulative: with no bubble to append
|
|
933
|
+
* into, the first card of a session would otherwise render nothing.
|
|
934
|
+
*/
|
|
935
|
+
const applyAccumulated = (before: number, segments: MessageSegment[]): void => {
|
|
936
|
+
if (isInStream || !hasEverStreamed) {
|
|
937
|
+
emitSegments(segments)
|
|
938
|
+
applySegmentsToState(segments, withSeqMeta(undefined))
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
const added = segments.slice(before)
|
|
942
|
+
if (added.length === 0) return
|
|
943
|
+
emitSegments(added, { append: true })
|
|
944
|
+
applySegmentsToState(added, withSeqMeta({ append: true }))
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (event.type === 'participant' && event.kind === 'direct-message') {
|
|
948
|
+
sawDirectMessage = true
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if ((directModeFlag || sawDirectMessage) && !isDirectModeAllowed(event)) {
|
|
952
|
+
// Hard stop: tear down AI activity exactly once so the typing
|
|
953
|
+
// indicator, composer lock, and half-rendered bubble clear, then drop
|
|
954
|
+
// the event. Fires even when no stream is open — an agent-busy lock
|
|
955
|
+
// (approved commands executing post-MESSAGE_END) has no other release
|
|
956
|
+
// once the barrier starts dropping the continuation chunks.
|
|
957
|
+
if (isInStream || !directTeardownFired) {
|
|
958
|
+
isInStream = false
|
|
959
|
+
directTeardownFired = true
|
|
960
|
+
emit('onStreamEnd')
|
|
961
|
+
setPhaseInternal('idle')
|
|
962
|
+
accumulator.resetSegments()
|
|
963
|
+
}
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
switch (event.type) {
|
|
968
|
+
case 'turn-start': {
|
|
969
|
+
isInStream = true
|
|
970
|
+
hasEverStreamed = true
|
|
971
|
+
turnStartedAt = Date.now()
|
|
972
|
+
emit('onStreamStart')
|
|
973
|
+
applyStreamStartToState()
|
|
974
|
+
accumulator.resetSegments()
|
|
975
|
+
break
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
case 'turn-end': {
|
|
979
|
+
isInStream = false
|
|
980
|
+
purgeEchoesAtTurnEnd()
|
|
981
|
+
emit('onStreamEnd')
|
|
982
|
+
setPhaseInternal('idle')
|
|
983
|
+
accumulator.resetSegments()
|
|
984
|
+
break
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
case 'metadata': {
|
|
988
|
+
// Legacy `parseChunkToAction` action shape, reconstructed for the
|
|
989
|
+
// callback contract.
|
|
990
|
+
emit('onMetadata', {
|
|
991
|
+
action: 'metadata',
|
|
992
|
+
modelDisplayName: event.modelLabel ?? undefined,
|
|
993
|
+
modelName: event.modelName,
|
|
994
|
+
providerName: event.provider,
|
|
995
|
+
contextWindow: event.contextWindowMaxTokens,
|
|
996
|
+
})
|
|
997
|
+
liveModel = {
|
|
998
|
+
provider: event.provider || null,
|
|
999
|
+
modelLabel: event.modelLabel || event.modelName || null,
|
|
1000
|
+
contextWindowMaxTokens: event.contextWindowMaxTokens || null,
|
|
1001
|
+
}
|
|
1002
|
+
invalidate()
|
|
1003
|
+
break
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// The three APPEND-ONLY body streams share one shape: accumulate the
|
|
1007
|
+
// verbatim slice into the trailing segment of the matching kind (the
|
|
1008
|
+
// accumulator coalesces), then emit. `guide-delta` is NATS-only today
|
|
1009
|
+
// but rides the identical path so a guide body streams, coalesces and
|
|
1010
|
+
// continues post-MESSAGE_END exactly like answer text.
|
|
1011
|
+
case 'text-delta':
|
|
1012
|
+
case 'thinking-delta':
|
|
1013
|
+
case 'guide-delta': {
|
|
1014
|
+
const kind =
|
|
1015
|
+
event.type === 'text-delta'
|
|
1016
|
+
? 'text'
|
|
1017
|
+
: event.type === 'thinking-delta'
|
|
1018
|
+
? 'thinking'
|
|
1019
|
+
: 'guide'
|
|
1020
|
+
const segments =
|
|
1021
|
+
kind === 'text'
|
|
1022
|
+
? accumulator.appendText(event.text)
|
|
1023
|
+
: kind === 'thinking'
|
|
1024
|
+
? accumulator.appendThinking(event.text)
|
|
1025
|
+
: accumulator.appendGuide(event.text)
|
|
1026
|
+
// Append-mode only for *true* post-stream continuation (after a
|
|
1027
|
+
// MESSAGE_END we actually saw). Cold-start chunks (no prior
|
|
1028
|
+
// MESSAGE_START) emit cumulative segments so the consumer can spawn
|
|
1029
|
+
// the first assistant bubble.
|
|
1030
|
+
if (isInStream || !hasEverStreamed) {
|
|
1031
|
+
emitSegments(segments)
|
|
1032
|
+
applySegmentsToState(segments, withSeqMeta(undefined))
|
|
1033
|
+
} else {
|
|
1034
|
+
const delta: MessageSegment[] = [{ type: kind, text: event.text }]
|
|
1035
|
+
emitSegments(delta, { append: true })
|
|
1036
|
+
applySegmentsToState(delta, withSeqMeta({ append: true }))
|
|
1037
|
+
}
|
|
1038
|
+
break
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
case 'tool-execution': {
|
|
1042
|
+
const segment: ToolExecutionSegment = { type: 'tool_execution', data: event.data }
|
|
1043
|
+
// A starting tool run means the agent's turn is in progress even
|
|
1044
|
+
// when this lands after MESSAGE_END (approved commands execute
|
|
1045
|
+
// between the approval bubble and the continuation stream).
|
|
1046
|
+
if (event.data.type === 'EXECUTING_TOOL') {
|
|
1047
|
+
emit('onAgentBusy')
|
|
1048
|
+
agentBusyState()
|
|
1049
|
+
}
|
|
1050
|
+
// Post-MESSAGE_END tool chunks (cancellations / async batch results
|
|
1051
|
+
// for a batch in a prior bubble) flow only through the cross-message
|
|
1052
|
+
// updater. Skipping the accumulator avoids pushing a standalone
|
|
1053
|
+
// segment that the next text chunk would replay into a new bubble.
|
|
1054
|
+
if (!isInStream && crossMessageToolRouting) {
|
|
1055
|
+
emit('onToolExecuted', segment)
|
|
1056
|
+
setMessagesInternal(applyToolExecutionToMessages(messages, segment))
|
|
1057
|
+
break
|
|
1058
|
+
}
|
|
1059
|
+
// In-stream: accumulator-driven update of the streaming bubble is
|
|
1060
|
+
// the source of truth. The cross-message scan is first-match-wins
|
|
1061
|
+
// and could touch a same-execId segment in a prior bubble (agent
|
|
1062
|
+
// retry case), so it is NOT run here.
|
|
1063
|
+
const segments = accumulator.addToolExecution(segment)
|
|
1064
|
+
emitSegments(segments)
|
|
1065
|
+
applySegmentsToState(segments, withSeqMeta(undefined))
|
|
1066
|
+
break
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
case 'approval-request': {
|
|
1070
|
+
const requestId = event.requestId
|
|
1071
|
+
const approvalType = event.approvalType ?? 'USER'
|
|
1072
|
+
const toolCalls = event.toolCalls
|
|
1073
|
+
|
|
1074
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
1075
|
+
// ── Batch form ──
|
|
1076
|
+
const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
|
|
1077
|
+
if (!displayApprovalTypes.includes(approvalType)) {
|
|
1078
|
+
// Escalated: keep batch context locally for replay on result;
|
|
1079
|
+
// surface a summary command via the legacy escalation callback.
|
|
1080
|
+
const required = toolCalls.find((c) => c.requiresApproval) ?? toolCalls[0]
|
|
1081
|
+
const summary = required
|
|
1082
|
+
? getCommandText(required)
|
|
1083
|
+
: `Batch of ${toolCalls.length} tool calls`
|
|
1084
|
+
pendingEscalated.set(requestId, {
|
|
1085
|
+
command: summary,
|
|
1086
|
+
explanation: required?.toolExplanation,
|
|
1087
|
+
approvalType,
|
|
1088
|
+
toolCalls,
|
|
1089
|
+
})
|
|
1090
|
+
emit('onEscalatedApproval', requestId, {
|
|
1091
|
+
command: summary,
|
|
1092
|
+
explanation: required?.toolExplanation,
|
|
1093
|
+
approvalType,
|
|
1094
|
+
})
|
|
1095
|
+
break
|
|
1096
|
+
}
|
|
1097
|
+
if (batchApprovalsEnabled) {
|
|
1098
|
+
const before = accumulator.getSegments().length
|
|
1099
|
+
const segments = accumulator.addApprovalBatch(requestId, approvalType, toolCalls, status)
|
|
1100
|
+
applyAccumulated(before, segments)
|
|
1101
|
+
break
|
|
1102
|
+
}
|
|
1103
|
+
// Flag OFF — unfold batch into N legacy approval cards. They share
|
|
1104
|
+
// `requestId`, so a click on any will approve the whole batch via a
|
|
1105
|
+
// single backend call, and the resulting APPROVAL_RESULT event will
|
|
1106
|
+
// flip status on every matching segment.
|
|
1107
|
+
let segments = accumulator.getSegments()
|
|
1108
|
+
const before = segments.length
|
|
1109
|
+
for (const call of toolCalls) {
|
|
1110
|
+
if (!call.requiresApproval) continue
|
|
1111
|
+
segments = accumulator.addApprovalRequest(
|
|
1112
|
+
requestId,
|
|
1113
|
+
getCommandText(call),
|
|
1114
|
+
call.toolExplanation,
|
|
1115
|
+
approvalType,
|
|
1116
|
+
status,
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
1119
|
+
applyAccumulated(before, segments)
|
|
1120
|
+
break
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ── Single form ──
|
|
1124
|
+
const command = event.command ?? ''
|
|
1125
|
+
const explanation = event.explanation
|
|
1126
|
+
if (displayApprovalTypes.includes(approvalType)) {
|
|
1127
|
+
const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
|
|
1128
|
+
const before = accumulator.getSegments().length
|
|
1129
|
+
const segments = accumulator.addApprovalRequest(
|
|
1130
|
+
requestId,
|
|
1131
|
+
command,
|
|
1132
|
+
explanation,
|
|
1133
|
+
approvalType,
|
|
1134
|
+
status,
|
|
1135
|
+
)
|
|
1136
|
+
applyAccumulated(before, segments)
|
|
1137
|
+
} else {
|
|
1138
|
+
pendingEscalated.set(requestId, { command, explanation, approvalType })
|
|
1139
|
+
emit('onEscalatedApproval', requestId, { command, explanation, approvalType })
|
|
1140
|
+
}
|
|
1141
|
+
break
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
case 'approval-resolved': {
|
|
1145
|
+
const requestId = event.requestId ?? ''
|
|
1146
|
+
const status = event.status
|
|
1147
|
+
const approved = status === 'approved'
|
|
1148
|
+
const approvalType = event.approvalType ?? 'CLIENT'
|
|
1149
|
+
const resolvedByName = event.resolvedByName
|
|
1150
|
+
// Approved → the agent resumes to execute the command(s); surface
|
|
1151
|
+
// busy immediately so the composer locks before EXECUTING_TOOL
|
|
1152
|
+
// lands. Rejection keeps the input free — the user may want to type
|
|
1153
|
+
// a correction right away.
|
|
1154
|
+
if (approved) {
|
|
1155
|
+
emit('onAgentBusy')
|
|
1156
|
+
agentBusyState()
|
|
1157
|
+
}
|
|
1158
|
+
const escalatedData = pendingEscalated.get(requestId)
|
|
1159
|
+
|
|
1160
|
+
if (escalatedData) {
|
|
1161
|
+
pendingEscalated.delete(requestId)
|
|
1162
|
+
emit('onEscalatedApprovalResult', requestId, approved, {
|
|
1163
|
+
command: escalatedData.command,
|
|
1164
|
+
explanation: escalatedData.explanation,
|
|
1165
|
+
approvalType: escalatedData.approvalType,
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
// The escalated card was never displayed inline, so this emit is
|
|
1169
|
+
// what surfaces it after resolution. In-stream the cumulative
|
|
1170
|
+
// array is correct; post-MESSAGE_END the accumulator was RESET, so
|
|
1171
|
+
// a cumulative (replace-mode) emit would wipe the trailing bubble
|
|
1172
|
+
// down to the lone card — emit only the resolved card(s) as an
|
|
1173
|
+
// append instead. Appends upsert by request id, so a replayed
|
|
1174
|
+
// append stays idempotent.
|
|
1175
|
+
const emitResolved = (segments: MessageSegment[]) => {
|
|
1176
|
+
if (isInStream) {
|
|
1177
|
+
emitSegments(segments)
|
|
1178
|
+
applySegmentsToState(segments, withSeqMeta(undefined))
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
const delta = segments.filter(
|
|
1182
|
+
(s) =>
|
|
1183
|
+
(s.type === 'approval_request' && s.data.requestId === requestId) ||
|
|
1184
|
+
(s.type === 'approval_batch' && s.data.approvalRequestId === requestId),
|
|
1185
|
+
)
|
|
1186
|
+
emitSegments(delta, { append: true })
|
|
1187
|
+
applySegmentsToState(delta, withSeqMeta({ append: true }))
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (escalatedData.toolCalls && escalatedData.toolCalls.length > 0) {
|
|
1191
|
+
if (batchApprovalsEnabled) {
|
|
1192
|
+
emitResolved(
|
|
1193
|
+
accumulator.addApprovalBatch(
|
|
1194
|
+
requestId,
|
|
1195
|
+
escalatedData.approvalType,
|
|
1196
|
+
escalatedData.toolCalls,
|
|
1197
|
+
status,
|
|
1198
|
+
undefined,
|
|
1199
|
+
resolvedByName,
|
|
1200
|
+
),
|
|
1201
|
+
)
|
|
1202
|
+
} else {
|
|
1203
|
+
let segments = accumulator.getSegments()
|
|
1204
|
+
for (const call of escalatedData.toolCalls) {
|
|
1205
|
+
if (!call.requiresApproval) continue
|
|
1206
|
+
segments = accumulator.addApprovalRequest(
|
|
1207
|
+
requestId,
|
|
1208
|
+
getCommandText(call),
|
|
1209
|
+
call.toolExplanation,
|
|
1210
|
+
escalatedData.approvalType,
|
|
1211
|
+
status,
|
|
1212
|
+
)
|
|
1213
|
+
}
|
|
1214
|
+
emitResolved(segments)
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
emitResolved(
|
|
1218
|
+
accumulator.addApprovalRequest(
|
|
1219
|
+
requestId,
|
|
1220
|
+
escalatedData.command,
|
|
1221
|
+
escalatedData.explanation,
|
|
1222
|
+
escalatedData.approvalType,
|
|
1223
|
+
status,
|
|
1224
|
+
),
|
|
1225
|
+
)
|
|
1226
|
+
}
|
|
1227
|
+
} else {
|
|
1228
|
+
// Always keep the in-memory accumulator in sync so a following
|
|
1229
|
+
// text/tool event replays the resolved status into the message.
|
|
1230
|
+
accumulator.updateApprovalStatus(requestId, status, resolvedByName)
|
|
1231
|
+
// Conditional legacy emission — forwarded as onSegmentsUpdate only
|
|
1232
|
+
// when the consumer did NOT wire onApprovalResolved (the wrapper
|
|
1233
|
+
// resolves the condition at dispatch time).
|
|
1234
|
+
emit('segments-after-approval-result', accumulator.getSegments(), withSeqMeta(undefined))
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Cross-message status flip + status-map mirror (absorbed adapter
|
|
1238
|
+
// handler) — runs for both branches, matching the legacy adapter
|
|
1239
|
+
// whose onApprovalResolved callback fired unconditionally.
|
|
1240
|
+
setMessagesInternal(
|
|
1241
|
+
projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
|
|
1242
|
+
)
|
|
1243
|
+
mirrorApprovalStatus(requestId, status)
|
|
1244
|
+
emit('onApprovalResolved', requestId, status, approvalType, resolvedByName)
|
|
1245
|
+
break
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
case 'error': {
|
|
1249
|
+
let message: string | undefined
|
|
1250
|
+
if (event.details) {
|
|
1251
|
+
try {
|
|
1252
|
+
message = JSON.parse(event.details)?.error?.message
|
|
1253
|
+
} catch {
|
|
1254
|
+
message = event.details
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const before = accumulator.getSegments().length
|
|
1258
|
+
const segments = accumulator.addError(event.title, message)
|
|
1259
|
+
applyAccumulated(before, segments)
|
|
1260
|
+
emit('onError', event.title, message)
|
|
1261
|
+
// Terminal turn failures can arrive without a MESSAGE_END; unlock
|
|
1262
|
+
// the composer unless an open stream still owns the phase.
|
|
1263
|
+
if (streamingPhase !== 'streaming') setPhaseInternal('idle')
|
|
1264
|
+
break
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
case 'participant': {
|
|
1268
|
+
if (event.kind === 'message-request') {
|
|
1269
|
+
emit('onUserMessage', event.text, {
|
|
1270
|
+
ownerType: event.ownerType,
|
|
1271
|
+
displayName: event.displayName,
|
|
1272
|
+
userId: event.userId,
|
|
1273
|
+
streamSeq: seq,
|
|
1274
|
+
contextItems: event.contextItems,
|
|
1275
|
+
})
|
|
1276
|
+
applyUserMessageToState(event)
|
|
1277
|
+
} else if (event.kind === 'direct-message') {
|
|
1278
|
+
emit('onDirectMessage', event.text, {
|
|
1279
|
+
ownerType: event.ownerType,
|
|
1280
|
+
displayName: event.displayName,
|
|
1281
|
+
userId: event.userId,
|
|
1282
|
+
streamSeq: seq,
|
|
1283
|
+
})
|
|
1284
|
+
applyDirectMessageToState(event)
|
|
1285
|
+
} else {
|
|
1286
|
+
emit('onSystemMessage', event.text, { streamSeq: seq })
|
|
1287
|
+
applySystemMessageToState(event)
|
|
1288
|
+
}
|
|
1289
|
+
break
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
case 'token-usage': {
|
|
1293
|
+
const data = {
|
|
1294
|
+
inputTokensSize: event.inputTokensSize,
|
|
1295
|
+
outputTokensSize: event.outputTokensSize,
|
|
1296
|
+
totalTokensSize: event.totalTokensSize,
|
|
1297
|
+
contextSize: event.contextSize,
|
|
1298
|
+
}
|
|
1299
|
+
emit('onTokenUsage', data)
|
|
1300
|
+
dialogTokenUsage = data
|
|
1301
|
+
invalidate()
|
|
1302
|
+
break
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
case 'compaction': {
|
|
1306
|
+
const standalone = !isInStream
|
|
1307
|
+
const segments =
|
|
1308
|
+
event.phase === 'start'
|
|
1309
|
+
? accumulator.addContextCompaction()
|
|
1310
|
+
: accumulator.completeContextCompaction(event.summary)
|
|
1311
|
+
const meta = standalone ? ({ append: true, isCompacting: true } as const) : undefined
|
|
1312
|
+
emitSegments(segments, meta)
|
|
1313
|
+
applySegmentsToState(segments, withSeqMeta(meta))
|
|
1314
|
+
break
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
case 'dialog-closed': {
|
|
1318
|
+
emit('onDialogClosed')
|
|
1319
|
+
break
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
default:
|
|
1323
|
+
// Unknown / SSE-only event on the NATS kernel — ignore.
|
|
1324
|
+
break
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// ===========================================================================
|
|
1329
|
+
// SSE kernel — the absorbed useChat merge + decision_resolved receipt path
|
|
1330
|
+
// ===========================================================================
|
|
1331
|
+
|
|
1332
|
+
/** Replace the trailing text segment of the LAST assistant message with
|
|
1333
|
+
* the cumulative turn text (or push one). Mirrors `useChat`'s
|
|
1334
|
+
* currentTextSegment handling byte-for-byte. */
|
|
1335
|
+
function sseWriteTrailingText(text: string): void {
|
|
1336
|
+
const last = messages[messages.length - 1]
|
|
1337
|
+
if (!last || last.role !== 'assistant') return
|
|
1338
|
+
const segments = [...(last.segments ?? [])]
|
|
1339
|
+
const tail = segments[segments.length - 1]
|
|
1340
|
+
if (tail && tail.type === 'text') {
|
|
1341
|
+
segments[segments.length - 1] = { type: 'text', text }
|
|
1342
|
+
} else {
|
|
1343
|
+
segments.push({ type: 'text', text })
|
|
1344
|
+
}
|
|
1345
|
+
setMessagesInternal([...messages.slice(0, -1), { ...last, segments }])
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function sseAppendText(text: string): void {
|
|
1349
|
+
sseCurrentText += text
|
|
1350
|
+
sseWriteTrailingText(sseCurrentText)
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Single thinking segment per message, ALWAYS before the answer text:
|
|
1354
|
+
* replaced in place when present, inserted at the FRONT otherwise. */
|
|
1355
|
+
function sseAppendThinking(escapedDelta: string): void {
|
|
1356
|
+
const last = messages[messages.length - 1]
|
|
1357
|
+
if (!last || last.role !== 'assistant') return
|
|
1358
|
+
const segments = [...(last.segments ?? [])]
|
|
1359
|
+
const idx = segments.findIndex((s) => s.type === 'thinking')
|
|
1360
|
+
if (idx !== -1) {
|
|
1361
|
+
const existing = segments[idx] as { type: 'thinking'; text: string }
|
|
1362
|
+
segments[idx] = { type: 'thinking', text: existing.text + escapedDelta }
|
|
1363
|
+
} else {
|
|
1364
|
+
segments.unshift({ type: 'thinking', text: escapedDelta })
|
|
1365
|
+
}
|
|
1366
|
+
setMessagesInternal([...messages.slice(0, -1), { ...last, segments }])
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function mergeTurnMeta(sendIdx: number, partial: Partial<ChatTurnMeta>): void {
|
|
1370
|
+
const prev = metaMap.get(sendIdx) ?? createEmptyTurnMeta()
|
|
1371
|
+
const filtered = Object.fromEntries(
|
|
1372
|
+
Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
|
|
1373
|
+
) as Partial<ChatTurnMeta>
|
|
1374
|
+
metaMap.set(sendIdx, { ...prev, ...filtered })
|
|
1375
|
+
invalidate()
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function applySseApprovalResolved(event: ApprovalResolvedEvent, sendIdx: number): void {
|
|
1379
|
+
// Inline post-approve card ref → per-send refs map (the marker in the
|
|
1380
|
+
// receipt resolves through it on rehydration).
|
|
1381
|
+
const cardRef = event.cardRef as ChatRef | undefined
|
|
1382
|
+
if (cardRef?.id && event.cardType) {
|
|
1383
|
+
const existing = refsMap.get(sendIdx) ?? {}
|
|
1384
|
+
const key = buildChatRefKey(event.cardType, cardRef.id)
|
|
1385
|
+
refsMap.set(sendIdx, { ...existing, [key]: cardRef })
|
|
1386
|
+
invalidate()
|
|
1387
|
+
}
|
|
1388
|
+
const proposalId = event.requestId
|
|
1389
|
+
if (!proposalId) return
|
|
1390
|
+
|
|
1391
|
+
// Step 1 — flip the SOURCE card's status (the approval card lives in an
|
|
1392
|
+
// EARLIER assistant message than the one being streamed into).
|
|
1393
|
+
setMessagesInternal(
|
|
1394
|
+
projectApprovalResolutionToMessages(messages, proposalId, event.status),
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
// Step 2 — server-rendered receipt into the CURRENT message. No
|
|
1398
|
+
// server-provided copy → don't fabricate a fallback.
|
|
1399
|
+
const receipt = typeof event.receiptText === 'string' ? event.receiptText : null
|
|
1400
|
+
if (receipt === null) return
|
|
1401
|
+
sseCurrentText = receipt + '\n\n'
|
|
1402
|
+
sseWriteTrailingText(sseCurrentText)
|
|
1403
|
+
|
|
1404
|
+
// Step 3 — stamp the ref onto THIS assistant message so the
|
|
1405
|
+
// `[card://<type>:<id>]` marker resolves via `message.chatRefs`
|
|
1406
|
+
// independent of per-turn refsMap indexing.
|
|
1407
|
+
const refForMessage =
|
|
1408
|
+
cardRef && typeof (cardRef as { type?: unknown }).type === 'string' && typeof cardRef.id === 'string'
|
|
1409
|
+
? cardRef
|
|
1410
|
+
: null
|
|
1411
|
+
if (refForMessage) {
|
|
1412
|
+
const last = messages[messages.length - 1]
|
|
1413
|
+
if (last && last.role === 'assistant') {
|
|
1414
|
+
const refKey = buildChatRefKey((refForMessage as { type: string }).type, refForMessage.id)
|
|
1415
|
+
setMessagesInternal([
|
|
1416
|
+
...messages.slice(0, -1),
|
|
1417
|
+
{ ...last, chatRefs: { ...(last.chatRefs ?? {}), [refKey]: refForMessage } },
|
|
1418
|
+
])
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function applySseMetadata(event: ChatMetadataEvent, sendIdx: number): void {
|
|
1424
|
+
if (event.routing) {
|
|
1425
|
+
mergeTurnMeta(sendIdx, {
|
|
1426
|
+
routedComplexity: event.routing.routedComplexity,
|
|
1427
|
+
routedThinkingBudget: event.routing.routedThinkingBudget,
|
|
1428
|
+
})
|
|
1429
|
+
return
|
|
1430
|
+
}
|
|
1431
|
+
if (event.sources) {
|
|
1432
|
+
sourcesMap.set(sendIdx, event.sources as unknown[])
|
|
1433
|
+
invalidate()
|
|
1434
|
+
}
|
|
1435
|
+
if (event.refs && typeof event.refs === 'object') {
|
|
1436
|
+
refsMap.set(sendIdx, event.refs as Record<string, ChatRef>)
|
|
1437
|
+
invalidate()
|
|
1438
|
+
}
|
|
1439
|
+
if (event.modelLabel || event.contextWindowMaxTokens || event.provider || event.modelName) {
|
|
1440
|
+
mergeTurnMeta(sendIdx, {
|
|
1441
|
+
provider: event.provider ?? null,
|
|
1442
|
+
modelLabel: event.modelLabel ?? null,
|
|
1443
|
+
contextWindowMaxTokens: event.contextWindowMaxTokens ?? null,
|
|
1444
|
+
})
|
|
1445
|
+
}
|
|
1446
|
+
const parsedAnchor = parseScrollAnchor(event.scrollAnchor)
|
|
1447
|
+
if (parsedAnchor !== null) {
|
|
1448
|
+
mergeTurnMeta(sendIdx, { scrollAnchor: parsedAnchor })
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function applySseUsage(event: UsageEvent, sendIdx: number): void {
|
|
1453
|
+
if (event.stage === 'start') {
|
|
1454
|
+
mergeTurnMeta(sendIdx, { inputTokens: event.input_tokens ?? null })
|
|
1455
|
+
return
|
|
1456
|
+
}
|
|
1457
|
+
// stage === 'end' — the trailing usage frame. Raw wire values pass the
|
|
1458
|
+
// legacy truthiness/typeof gates so a malformed frame degrades
|
|
1459
|
+
// identically to the pre-SSOT parser.
|
|
1460
|
+
const rawBreakdown = event.breakdown as Record<string, any> | null | undefined
|
|
1461
|
+
const breakdown: UnifiedUsageBreakdown | null =
|
|
1462
|
+
rawBreakdown && typeof rawBreakdown === 'object'
|
|
1463
|
+
? {
|
|
1464
|
+
haikuRewriter:
|
|
1465
|
+
rawBreakdown.haikuRewriter && typeof rawBreakdown.haikuRewriter.input === 'number'
|
|
1466
|
+
? rawBreakdown.haikuRewriter
|
|
1467
|
+
: undefined,
|
|
1468
|
+
haikuClassifier:
|
|
1469
|
+
rawBreakdown.haikuClassifier && typeof rawBreakdown.haikuClassifier.input === 'number'
|
|
1470
|
+
? rawBreakdown.haikuClassifier
|
|
1471
|
+
: undefined,
|
|
1472
|
+
haikuSummarizer:
|
|
1473
|
+
rawBreakdown.haikuSummarizer && typeof rawBreakdown.haikuSummarizer.input === 'number'
|
|
1474
|
+
? rawBreakdown.haikuSummarizer
|
|
1475
|
+
: undefined,
|
|
1476
|
+
routedAnswer:
|
|
1477
|
+
rawBreakdown.routedAnswer && typeof rawBreakdown.routedAnswer.model === 'string'
|
|
1478
|
+
? rawBreakdown.routedAnswer
|
|
1479
|
+
: undefined,
|
|
1480
|
+
}
|
|
1481
|
+
: null
|
|
1482
|
+
mergeTurnMeta(sendIdx, {
|
|
1483
|
+
inputTokens: event.input_tokens ?? null,
|
|
1484
|
+
outputTokens: event.output_tokens ?? null,
|
|
1485
|
+
cacheHitRatePct: typeof event.hit_rate_pct === 'number' ? event.hit_rate_pct : null,
|
|
1486
|
+
...(breakdown ? { breakdown } : {}),
|
|
1487
|
+
})
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function applySse(event: ChatStreamEvent): void {
|
|
1491
|
+
const sendIdx = sendCount - 1
|
|
1492
|
+
switch (event.type) {
|
|
1493
|
+
case 'status':
|
|
1494
|
+
setPhaseInternal('thinking')
|
|
1495
|
+
break
|
|
1496
|
+
case 'thinking-delta':
|
|
1497
|
+
sseAppendThinking(escapeThinkingTags(event.text))
|
|
1498
|
+
break
|
|
1499
|
+
case 'turn-start':
|
|
1500
|
+
setPhaseInternal('streaming')
|
|
1501
|
+
break
|
|
1502
|
+
case 'text-delta':
|
|
1503
|
+
if (!event.leading) setPhaseInternal('streaming')
|
|
1504
|
+
sseAppendText(event.text)
|
|
1505
|
+
break
|
|
1506
|
+
case 'error':
|
|
1507
|
+
// Legacy parity: SSE tool errors surface as answer text.
|
|
1508
|
+
sseAppendText(`⚠️ ${event.title}`)
|
|
1509
|
+
break
|
|
1510
|
+
case 'approval-request': {
|
|
1511
|
+
// Flush any accumulated preamble text so the card lands after it.
|
|
1512
|
+
if (sseCurrentText) {
|
|
1513
|
+
sseWriteTrailingText(sseCurrentText)
|
|
1514
|
+
sseCurrentText = ''
|
|
1515
|
+
}
|
|
1516
|
+
const segment: ApprovalRequestSegment = {
|
|
1517
|
+
type: 'approval_request',
|
|
1518
|
+
data: {
|
|
1519
|
+
command: event.command ?? '',
|
|
1520
|
+
fields: event.fields,
|
|
1521
|
+
requestId: event.requestId,
|
|
1522
|
+
approvalType: event.approvalType,
|
|
1523
|
+
},
|
|
1524
|
+
status: 'pending',
|
|
1525
|
+
onApprove: callbacks.onApprove,
|
|
1526
|
+
onReject: callbacks.onReject,
|
|
1527
|
+
}
|
|
1528
|
+
const last = messages[messages.length - 1]
|
|
1529
|
+
if (last && last.role === 'assistant') {
|
|
1530
|
+
setMessagesInternal([
|
|
1531
|
+
...messages.slice(0, -1),
|
|
1532
|
+
{ ...last, segments: [...(last.segments ?? []), segment] },
|
|
1533
|
+
])
|
|
1534
|
+
}
|
|
1535
|
+
break
|
|
1536
|
+
}
|
|
1537
|
+
case 'approval-resolved':
|
|
1538
|
+
applySseApprovalResolved(event, sendIdx)
|
|
1539
|
+
break
|
|
1540
|
+
case 'metadata':
|
|
1541
|
+
applySseMetadata(event, sendIdx)
|
|
1542
|
+
break
|
|
1543
|
+
case 'usage':
|
|
1544
|
+
applySseUsage(event, sendIdx)
|
|
1545
|
+
break
|
|
1546
|
+
// NOTE: 'tool-execution' is intentionally NOT handled on the SSE
|
|
1547
|
+
// kernel — the SSE wire never carries it, and routing it through the
|
|
1548
|
+
// accumulator would invoke resolvePendingApprovalForExecution, which
|
|
1549
|
+
// is a NATS-only semantic (observer streams without APPROVAL_RESULT).
|
|
1550
|
+
default:
|
|
1551
|
+
break
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// ===========================================================================
|
|
1556
|
+
// Public surface
|
|
1557
|
+
// ===========================================================================
|
|
1558
|
+
|
|
1559
|
+
function apply(event: ChatStreamEvent): void {
|
|
1560
|
+
// seq-based idempotency: drop redelivered / out-of-order events.
|
|
1561
|
+
if (typeof event.seq === 'number') {
|
|
1562
|
+
if (event.seq <= lastAppliedSeq) return
|
|
1563
|
+
lastAppliedSeq = event.seq
|
|
1564
|
+
}
|
|
1565
|
+
if (transport === 'sse') applySse(event)
|
|
1566
|
+
else applyNats(event)
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function reset(): void {
|
|
1570
|
+
messages = []
|
|
1571
|
+
streamingPhase = 'idle'
|
|
1572
|
+
dialogTokenUsage = null
|
|
1573
|
+
liveModel = null
|
|
1574
|
+
approvalStatuses = {}
|
|
1575
|
+
accumulator.reset()
|
|
1576
|
+
pendingEscalated.clear()
|
|
1577
|
+
isInStream = false
|
|
1578
|
+
hasEverStreamed = false
|
|
1579
|
+
sawDirectMessage = false
|
|
1580
|
+
directTeardownFired = false
|
|
1581
|
+
lastAppliedSeq = Number.NEGATIVE_INFINITY
|
|
1582
|
+
pendingEchoTexts = []
|
|
1583
|
+
turnStartedAt = 0
|
|
1584
|
+
adoptTrailingAssistant = false
|
|
1585
|
+
metaMap.clear()
|
|
1586
|
+
sourcesMap.clear()
|
|
1587
|
+
refsMap.clear()
|
|
1588
|
+
sendCount = 0
|
|
1589
|
+
sseCurrentText = ''
|
|
1590
|
+
invalidate()
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Per-dialog reset. EVERYTHING scoped to one dialog is dropped; the two
|
|
1595
|
+
* deliberate survivors are called out below, because "the full reset minus
|
|
1596
|
+
* an explicit, argued list" is the only form of this function that does not
|
|
1597
|
+
* rot — a field added to `reset()` and forgotten here is a cross-dialog leak,
|
|
1598
|
+
* and that is exactly how `sseCurrentText` came to bleed (see the SSE block).
|
|
1599
|
+
*
|
|
1600
|
+
* SURVIVOR 1 — `approvalStatuses`: request ids are globally unique, and a
|
|
1601
|
+
* resolved approval must not re-render as actionable when its
|
|
1602
|
+
* APPROVAL_RESULT row is missing from the refetched history page.
|
|
1603
|
+
*
|
|
1604
|
+
* SURVIVOR 2 — `suppressAgentBusy`: NOT dialog state, but a refcount for an
|
|
1605
|
+
* IN-FLIGHT operation, owned by the caller's `+1` / `-1` pairing around a
|
|
1606
|
+
* catchup window. Zeroing it here would break that pairing in exactly the
|
|
1607
|
+
* race the suppression exists for: on an A → B → A re-entry, A's first
|
|
1608
|
+
* catchup can still be replaying its dead tail (`EXECUTING_TOOL` chunks whose
|
|
1609
|
+
* releasing `MESSAGE_END` never replays) when the switch back resets, so
|
|
1610
|
+
* clearing the count would let that replay lock the composer — the bug
|
|
1611
|
+
* `adjustAgentBusySuppression` was introduced to prevent. The pairing is
|
|
1612
|
+
* already safe across a switch (`Math.max(0, …)` absorbs an over-decrement
|
|
1613
|
+
* and each `finally` is scoped to its own captured dialogId), so leaving the
|
|
1614
|
+
* count alone is both correct and sufficient. Pinned by the
|
|
1615
|
+
* "agent-busy suppression survives a dialog switch" test.
|
|
1616
|
+
*/
|
|
1617
|
+
function resetForDialogSwitch(): void {
|
|
1618
|
+
messages = []
|
|
1619
|
+
streamingPhase = 'idle'
|
|
1620
|
+
dialogTokenUsage = null
|
|
1621
|
+
liveModel = null
|
|
1622
|
+
accumulator.reset()
|
|
1623
|
+
pendingEscalated.clear()
|
|
1624
|
+
isInStream = false
|
|
1625
|
+
hasEverStreamed = false
|
|
1626
|
+
sawDirectMessage = false
|
|
1627
|
+
directTeardownFired = false
|
|
1628
|
+
lastAppliedSeq = Number.NEGATIVE_INFINITY
|
|
1629
|
+
pendingEchoTexts = []
|
|
1630
|
+
turnStartedAt = 0
|
|
1631
|
+
adoptTrailingAssistant = false
|
|
1632
|
+
// SSE per-turn kernel. `sseCurrentText` is the CUMULATIVE answer text of
|
|
1633
|
+
// the turn being streamed, and `sseAppendText` only ever appends to it —
|
|
1634
|
+
// so leaving it behind prepends the previous dialog's whole answer to the
|
|
1635
|
+
// next `text-delta` that lands without an intervening `beginSseSend`
|
|
1636
|
+
// (the resumed-mid-turn shape: restore history, then live deltas flow).
|
|
1637
|
+
// The per-send maps are keyed by `sendCount`, which is likewise per-dialog;
|
|
1638
|
+
// a host that needs them restored has `seedSseMaps`.
|
|
1639
|
+
sseCurrentText = ''
|
|
1640
|
+
metaMap.clear()
|
|
1641
|
+
sourcesMap.clear()
|
|
1642
|
+
refsMap.clear()
|
|
1643
|
+
sendCount = 0
|
|
1644
|
+
invalidate()
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** Canonical state-monotonic merge of a persisted / parked status map.
|
|
1648
|
+
* "resolved beats pending" in BOTH directions — see the interface doc on
|
|
1649
|
+
* `mergeApprovalStatuses`, which is this function. */
|
|
1650
|
+
function mergeApprovalStatusesInternal(
|
|
1651
|
+
persisted: Record<string, ChatApprovalStatus>,
|
|
1652
|
+
): void {
|
|
1653
|
+
const keys = Object.keys(persisted)
|
|
1654
|
+
if (keys.length === 0) return
|
|
1655
|
+
let next: Record<string, ChatApprovalStatus> | null = null
|
|
1656
|
+
for (const key of keys) {
|
|
1657
|
+
const mine = approvalStatuses[key]
|
|
1658
|
+
const theirs = persisted[key]
|
|
1659
|
+
// Unknown here → adopt. Known → adopt only to upgrade pending →
|
|
1660
|
+
// resolved; never downgrade a resolution back to pending.
|
|
1661
|
+
const adopt = mine === undefined || (mine === 'pending' && theirs !== 'pending')
|
|
1662
|
+
if (!adopt || mine === theirs) continue
|
|
1663
|
+
next ??= { ...approvalStatuses }
|
|
1664
|
+
next[key] = theirs
|
|
1665
|
+
}
|
|
1666
|
+
if (next === null) return
|
|
1667
|
+
approvalStatuses = next
|
|
1668
|
+
invalidate()
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function initializeWithState(
|
|
1672
|
+
nextMessages: UnifiedChatMessage[] | null,
|
|
1673
|
+
extras?: InitializeExtras,
|
|
1674
|
+
): void {
|
|
1675
|
+
if (nextMessages !== null) {
|
|
1676
|
+
messages = nextMessages
|
|
1677
|
+
}
|
|
1678
|
+
if (extras) {
|
|
1679
|
+
accumulator.initializeWithState({
|
|
1680
|
+
existingSegments: extras.existingSegments,
|
|
1681
|
+
pendingApprovals: extras.pendingApprovals,
|
|
1682
|
+
executingTools: extras.executingTools,
|
|
1683
|
+
})
|
|
1684
|
+
if (extras.escalatedApprovals) {
|
|
1685
|
+
for (const [requestId, data] of extras.escalatedApprovals) {
|
|
1686
|
+
pendingEscalated.set(requestId, data)
|
|
1687
|
+
emit('onEscalatedApproval', requestId, data)
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
// Parked state from a previous instance of this key (see the fields'
|
|
1691
|
+
// docs). Both are restore-only: statuses merge monotonically, the seq
|
|
1692
|
+
// gate only moves forward.
|
|
1693
|
+
if (extras.approvalStatuses) mergeApprovalStatusesInternal(extras.approvalStatuses)
|
|
1694
|
+
if (typeof extras.lastAppliedSeq === 'number' && extras.lastAppliedSeq > lastAppliedSeq) {
|
|
1695
|
+
lastAppliedSeq = extras.lastAppliedSeq
|
|
1696
|
+
}
|
|
1697
|
+
if (extras.pendingEchoes && extras.pendingEchoes.length > 0) {
|
|
1698
|
+
const now = Date.now()
|
|
1699
|
+
const restored = extras.pendingEchoes.filter(
|
|
1700
|
+
(e) => now - e.at < OWN_ECHO_AUTHOR_TTL_MS,
|
|
1701
|
+
)
|
|
1702
|
+
if (restored.length > 0) {
|
|
1703
|
+
pendingEchoTexts = [...pendingEchoTexts, ...restored.map((e) => ({ ...e }))]
|
|
1704
|
+
if (pendingEchoTexts.length > MAX_PENDING_ECHOES) {
|
|
1705
|
+
pendingEchoTexts = pendingEchoTexts.slice(-MAX_PENDING_ECHOES)
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
// Resumed dialog: a MESSAGE_START already fired server-side. Treat
|
|
1711
|
+
// subsequent continuation chunks (after the next MESSAGE_END) as
|
|
1712
|
+
// post-stream so they append into the existing bubble instead of
|
|
1713
|
+
// replacing its content via the cold-start cumulative path.
|
|
1714
|
+
//
|
|
1715
|
+
// Gated on the restored thread being non-empty (overridable via
|
|
1716
|
+
// `extras.resumed`): the eviction round trip calls this for EVERY dropped
|
|
1717
|
+
// key, including one that never streamed and parked an empty thread.
|
|
1718
|
+
// Claiming "resumed" there would send a later cold text-delta with no
|
|
1719
|
+
// preceding `turn-start` down the append branch, so the first assistant
|
|
1720
|
+
// bubble never spawns.
|
|
1721
|
+
// Never DOWNGRADES an instance that already streamed — this only decides
|
|
1722
|
+
// whether the restore itself counts as evidence of a prior stream.
|
|
1723
|
+
hasEverStreamed = hasEverStreamed || (extras?.resumed ?? messages.length > 0)
|
|
1724
|
+
// A tail the extractor judged agent-busy restores the activity indicator.
|
|
1725
|
+
// Routed through `agentBusyState` so the restore obeys the SAME rule a live
|
|
1726
|
+
// busy signal does — only 'idle' upgrades, an open stream keeps ownership.
|
|
1727
|
+
if (extras?.agentBusy) agentBusyState()
|
|
1728
|
+
invalidate()
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
return {
|
|
1732
|
+
apply,
|
|
1733
|
+
get state() {
|
|
1734
|
+
return getState()
|
|
1735
|
+
},
|
|
1736
|
+
reset,
|
|
1737
|
+
initializeWithState,
|
|
1738
|
+
|
|
1739
|
+
setMessages(next) {
|
|
1740
|
+
messages = next
|
|
1741
|
+
invalidate()
|
|
1742
|
+
},
|
|
1743
|
+
prependMessages(older) {
|
|
1744
|
+
messages = [...older, ...messages]
|
|
1745
|
+
invalidate()
|
|
1746
|
+
},
|
|
1747
|
+
pushOptimisticSend(text, hidden = false) {
|
|
1748
|
+
// Record the text so the backend's MESSAGE_REQUEST echo consumes this
|
|
1749
|
+
// send instead of rendering a duplicate row (cap keeps the list from
|
|
1750
|
+
// growing if a backend never echoes).
|
|
1751
|
+
pendingEchoTexts.push({ text, at: Date.now() })
|
|
1752
|
+
if (pendingEchoTexts.length > MAX_PENDING_ECHOES) pendingEchoTexts.shift()
|
|
1753
|
+
messages = [
|
|
1754
|
+
...messages,
|
|
1755
|
+
// An EMPTY-text send is out-of-band metadata (an approval decision),
|
|
1756
|
+
// not something the user said — see `beginSseSend` for the full
|
|
1757
|
+
// rationale. Never materialize a row for it.
|
|
1758
|
+
...(text.length > 0
|
|
1759
|
+
? ([
|
|
1760
|
+
{
|
|
1761
|
+
id: nextId('user'),
|
|
1762
|
+
role: 'user' as const,
|
|
1763
|
+
content: text,
|
|
1764
|
+
...(hidden ? { hidden: true } : {}),
|
|
1765
|
+
},
|
|
1766
|
+
] satisfies UnifiedChatMessage[])
|
|
1767
|
+
: []),
|
|
1768
|
+
{ id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
|
|
1769
|
+
]
|
|
1770
|
+
streamingPhase = 'thinking'
|
|
1771
|
+
invalidate()
|
|
1772
|
+
},
|
|
1773
|
+
clearThread() {
|
|
1774
|
+
messages = []
|
|
1775
|
+
accumulator.reset()
|
|
1776
|
+
streamingPhase = 'idle'
|
|
1777
|
+
invalidate()
|
|
1778
|
+
},
|
|
1779
|
+
resetForDialogSwitch,
|
|
1780
|
+
|
|
1781
|
+
beginSseSend({ text, hidden, userName = 'You', assistantName, assistantAvatar }) {
|
|
1782
|
+
const now = Date.now()
|
|
1783
|
+
messages = [
|
|
1784
|
+
...messages,
|
|
1785
|
+
// Approve / Reject send `('', { hidden: true, approvalAction })`: the
|
|
1786
|
+
// decision is OUT-OF-BAND metadata (it rides `proposal_id`/`action` in
|
|
1787
|
+
// the request body), not a user utterance. The history builder already
|
|
1788
|
+
// filters `!m.hidden`, so such a row contributes NOTHING to the wire —
|
|
1789
|
+
// it is dead state whose only effect is a render hazard (an author
|
|
1790
|
+
// label with no body). So we never mint it. Hidden sends that DO carry
|
|
1791
|
+
// text (e.g. an auto-continuation directive) still become real state:
|
|
1792
|
+
// the LLM must see them, `Message.hidden` keeps them off screen.
|
|
1793
|
+
...(text.length > 0
|
|
1794
|
+
? ([
|
|
1795
|
+
{
|
|
1796
|
+
id: `user-${now}`,
|
|
1797
|
+
role: 'user' as const,
|
|
1798
|
+
name: userName,
|
|
1799
|
+
content: text,
|
|
1800
|
+
timestamp: new Date(now),
|
|
1801
|
+
...(hidden ? { hidden: true } : {}),
|
|
1802
|
+
},
|
|
1803
|
+
] satisfies UnifiedChatMessage[])
|
|
1804
|
+
: []),
|
|
1805
|
+
{
|
|
1806
|
+
id: `assistant-${now}`,
|
|
1807
|
+
role: 'assistant',
|
|
1808
|
+
name: assistantName,
|
|
1809
|
+
content: '',
|
|
1810
|
+
segments: [],
|
|
1811
|
+
timestamp: new Date(now),
|
|
1812
|
+
...(assistantAvatar !== undefined ? { avatar: assistantAvatar } : {}),
|
|
1813
|
+
},
|
|
1814
|
+
]
|
|
1815
|
+
sendCount += 1
|
|
1816
|
+
sseCurrentText = ''
|
|
1817
|
+
streamingPhase = 'thinking'
|
|
1818
|
+
invalidate()
|
|
1819
|
+
},
|
|
1820
|
+
endSseTurn() {
|
|
1821
|
+
// Reject-path on confirm-tool emits ONLY a `decision_resolved` leading
|
|
1822
|
+
// frame and closes — no text segments stream in, so the placeholder
|
|
1823
|
+
// assistant message would render as a blank bubble. Remove the
|
|
1824
|
+
// trailing assistant message if it still has no content.
|
|
1825
|
+
const last = messages[messages.length - 1]
|
|
1826
|
+
if (
|
|
1827
|
+
last &&
|
|
1828
|
+
last.role === 'assistant' &&
|
|
1829
|
+
(last.segments?.length ?? 0) === 0 &&
|
|
1830
|
+
last.content === ''
|
|
1831
|
+
) {
|
|
1832
|
+
messages = messages.slice(0, -1)
|
|
1833
|
+
}
|
|
1834
|
+
sseCurrentText = ''
|
|
1835
|
+
streamingPhase = 'idle'
|
|
1836
|
+
invalidate()
|
|
1837
|
+
},
|
|
1838
|
+
failSseTurn(errorMessage) {
|
|
1839
|
+
// Legacy `useChat` catch parity: the trailing (placeholder) message is
|
|
1840
|
+
// replaced by an error-role row.
|
|
1841
|
+
messages = [
|
|
1842
|
+
...messages.slice(0, -1),
|
|
1843
|
+
{
|
|
1844
|
+
id: `error-${Date.now()}`,
|
|
1845
|
+
role: 'error',
|
|
1846
|
+
content: errorMessage,
|
|
1847
|
+
timestamp: new Date(),
|
|
1848
|
+
} as unknown as UnifiedChatMessage,
|
|
1849
|
+
]
|
|
1850
|
+
sseCurrentText = ''
|
|
1851
|
+
streamingPhase = 'idle'
|
|
1852
|
+
invalidate()
|
|
1853
|
+
},
|
|
1854
|
+
seedSseMaps({ sources, refs, sendCount: seedSendCount }) {
|
|
1855
|
+
if (sources) for (const [k, v] of sources) sourcesMap.set(k, v)
|
|
1856
|
+
if (refs) for (const [k, v] of refs) refsMap.set(k, v)
|
|
1857
|
+
if (typeof seedSendCount === 'number') sendCount = seedSendCount
|
|
1858
|
+
invalidate()
|
|
1859
|
+
},
|
|
1860
|
+
|
|
1861
|
+
setPhase(phase) {
|
|
1862
|
+
setPhaseInternal(phase)
|
|
1863
|
+
},
|
|
1864
|
+
setApprovalStatus(requestId, status) {
|
|
1865
|
+
if (status === null) {
|
|
1866
|
+
if (!(requestId in approvalStatuses)) return
|
|
1867
|
+
const next = { ...approvalStatuses }
|
|
1868
|
+
delete next[requestId]
|
|
1869
|
+
approvalStatuses = next
|
|
1870
|
+
} else {
|
|
1871
|
+
if (approvalStatuses[requestId] === status) return
|
|
1872
|
+
approvalStatuses = { ...approvalStatuses, [requestId]: status }
|
|
1873
|
+
}
|
|
1874
|
+
invalidate()
|
|
1875
|
+
},
|
|
1876
|
+
mergeApprovalStatuses: mergeApprovalStatusesInternal,
|
|
1877
|
+
setDirectMode(isDirectMode) {
|
|
1878
|
+
directModeFlag = isDirectMode
|
|
1879
|
+
},
|
|
1880
|
+
setDialogTokenUsage(usage) {
|
|
1881
|
+
dialogTokenUsage = usage
|
|
1882
|
+
invalidate()
|
|
1883
|
+
},
|
|
1884
|
+
adjustAgentBusySuppression(delta) {
|
|
1885
|
+
suppressAgentBusy = Math.max(0, suppressAgentBusy + delta)
|
|
1886
|
+
},
|
|
1887
|
+
armAdoptTrailingAssistant(value) {
|
|
1888
|
+
adoptTrailingAssistant = value
|
|
1889
|
+
},
|
|
1890
|
+
|
|
1891
|
+
projectApprovalResolution(requestId, status, resolvedByName) {
|
|
1892
|
+
setMessagesInternal(
|
|
1893
|
+
projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
|
|
1894
|
+
)
|
|
1895
|
+
mirrorApprovalStatus(requestId, status)
|
|
1896
|
+
},
|
|
1897
|
+
projectToolExecution(segment) {
|
|
1898
|
+
// Merge-only: a projection must never grow a card on a side the tool
|
|
1899
|
+
// doesn't belong to, and must never re-enter the seq stream.
|
|
1900
|
+
const merged = mergeToolExecutionIfPresent(messages, segment)
|
|
1901
|
+
if (merged !== null) setMessagesInternal(merged)
|
|
1902
|
+
},
|
|
1903
|
+
|
|
1904
|
+
getLastAppliedSeq() {
|
|
1905
|
+
return lastAppliedSeq
|
|
1906
|
+
},
|
|
1907
|
+
getPendingEchoes() {
|
|
1908
|
+
// SNAPSHOT, not the live array: `pushOptimisticSend` pushes into and
|
|
1909
|
+
// `consumeOwnEcho` splices the same object, so handing it out would make
|
|
1910
|
+
// the `readonly` return type a lie and let a parked copy mutate under
|
|
1911
|
+
// its holder.
|
|
1912
|
+
return pendingEchoTexts.slice()
|
|
1913
|
+
},
|
|
1914
|
+
getSegments() {
|
|
1915
|
+
return accumulator.getSegments()
|
|
1916
|
+
},
|
|
1917
|
+
updateApprovalStatus(requestId, status, resolvedByName) {
|
|
1918
|
+
const segments = accumulator.updateApprovalStatus(requestId, status, resolvedByName)
|
|
1919
|
+
setMessagesInternal(
|
|
1920
|
+
projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
|
|
1921
|
+
)
|
|
1922
|
+
return segments
|
|
1923
|
+
},
|
|
1924
|
+
getPendingEscalated() {
|
|
1925
|
+
return new Map(pendingEscalated)
|
|
1926
|
+
},
|
|
1927
|
+
}
|
|
1928
|
+
}
|