@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,136 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { normalizeIpForBucketKey, IP_BUCKET_KEY_MAX_LENGTH } from '../ip-normalize'
|
|
3
|
+
|
|
4
|
+
describe('normalizeIpForBucketKey', () => {
|
|
5
|
+
it('passes plain IPv4 through', () => {
|
|
6
|
+
expect(normalizeIpForBucketKey('203.0.113.4')).toBe('203.0.113.4')
|
|
7
|
+
expect(normalizeIpForBucketKey(' 203.0.113.4 ')).toBe('203.0.113.4')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('range-checks IPv4 octets', () => {
|
|
11
|
+
expect(normalizeIpForBucketKey('203.0.113.256')).toBeNull()
|
|
12
|
+
expect(normalizeIpForBucketKey('203.0.113')).toBeNull()
|
|
13
|
+
expect(normalizeIpForBucketKey('203.0.113.4.5')).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('unwraps IPv4-mapped IPv6 to the bare IPv4 (one peer, one bucket)', () => {
|
|
17
|
+
expect(normalizeIpForBucketKey('::ffff:203.0.113.4')).toBe('203.0.113.4')
|
|
18
|
+
// Written-out spelling of the SAME mapped address must land in the same
|
|
19
|
+
// bucket — a hop that formats without zero-compression would otherwise
|
|
20
|
+
// split one peer in two.
|
|
21
|
+
expect(normalizeIpForBucketKey('0:0:0:0:0:ffff:203.0.113.4')).toBe('203.0.113.4')
|
|
22
|
+
expect(normalizeIpForBucketKey('0:0:0:0:0:FFFF:203.0.113.4')).toBe('203.0.113.4')
|
|
23
|
+
expect(normalizeIpForBucketKey('0:0:0:0:0:ffff:203.0.113.999')).toBeNull()
|
|
24
|
+
expect(normalizeIpForBucketKey('::FFFF:203.0.113.4')).toBe('203.0.113.4')
|
|
25
|
+
expect(normalizeIpForBucketKey('[::ffff:203.0.113.4]:443')).toBe('203.0.113.4')
|
|
26
|
+
expect(normalizeIpForBucketKey('::ffff:203.0.113.999')).toBeNull()
|
|
27
|
+
// ZERO-PADDED groups are the fixed-width spelling of the same mapped
|
|
28
|
+
// address. Leading zeros inside an IPv6 group are cosmetic, so this must
|
|
29
|
+
// reach the same bucket as `::ffff:` — not fall through to the IPv6 path.
|
|
30
|
+
expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:ffff:203.0.113.4')).toBe(
|
|
31
|
+
'203.0.113.4',
|
|
32
|
+
)
|
|
33
|
+
expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:FFFF:203.0.113.4')).toBe(
|
|
34
|
+
'203.0.113.4',
|
|
35
|
+
)
|
|
36
|
+
// Mixed padding widths (a formatter that pads only some groups).
|
|
37
|
+
expect(normalizeIpForBucketKey('00:000:0:0000:0:ffff:203.0.113.4')).toBe('203.0.113.4')
|
|
38
|
+
// The embedded IPv4 is still range-checked in the padded spelling.
|
|
39
|
+
expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:ffff:203.0.113.999')).toBeNull()
|
|
40
|
+
// …and its octets still may not be zero-padded (two spellings of one host).
|
|
41
|
+
expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:ffff:203.0.113.04')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('strips brackets and a trailing port', () => {
|
|
45
|
+
expect(normalizeIpForBucketKey('[::1]:443')).toBe('::1')
|
|
46
|
+
expect(normalizeIpForBucketKey('[2001:db8::1]')).toBe('2001:db8::1')
|
|
47
|
+
// Unbalanced / junk trailer is not an address.
|
|
48
|
+
expect(normalizeIpForBucketKey('[::1')).toBeNull()
|
|
49
|
+
expect(normalizeIpForBucketKey('[::1]junk')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('strips the %zone scope id', () => {
|
|
53
|
+
expect(normalizeIpForBucketKey('fe80::1%eth0')).toBe('fe80::1')
|
|
54
|
+
expect(normalizeIpForBucketKey('[fe80::1%25eth0]:9000')).toBe('fe80::1')
|
|
55
|
+
expect(normalizeIpForBucketKey('%eth0')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('lower-cases IPv6 so casing cannot split a bucket', () => {
|
|
59
|
+
expect(normalizeIpForBucketKey('2001:DB8::AB')).toBe('2001:db8::ab')
|
|
60
|
+
expect(normalizeIpForBucketKey('2001:db8::ab')).toBe('2001:db8::ab')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('accepts full and compressed IPv6 shapes', () => {
|
|
64
|
+
expect(normalizeIpForBucketKey('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe(
|
|
65
|
+
'2001:0db8:0000:0000:0000:0000:0000:0001',
|
|
66
|
+
)
|
|
67
|
+
expect(normalizeIpForBucketKey('::')).toBe('::')
|
|
68
|
+
expect(normalizeIpForBucketKey('64:ff9b::203.0.113.4')).toBe('64:ff9b::203.0.113.4')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects malformed IPv6', () => {
|
|
72
|
+
expect(normalizeIpForBucketKey('2001:db8::1::2')).toBeNull()
|
|
73
|
+
expect(normalizeIpForBucketKey('2001:db8:::1')).toBeNull()
|
|
74
|
+
expect(normalizeIpForBucketKey('1:2:3:4:5:6:7:8:9')).toBeNull()
|
|
75
|
+
expect(normalizeIpForBucketKey('2001:db8:0:0:0:0:1')).toBeNull()
|
|
76
|
+
expect(normalizeIpForBucketKey('2001:zzzz::1')).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* REGRESSION (round 4): Azure App Service / Front Door and several CDNs put
|
|
81
|
+
* `ip:port` in the XFF hop. Returning null there left the caller with NO
|
|
82
|
+
* candidate, collapsing every visitor into the single egress bucket.
|
|
83
|
+
*/
|
|
84
|
+
it('strips a bare :port from an IPv4 hop, but not from an ambiguous IPv6', () => {
|
|
85
|
+
expect(normalizeIpForBucketKey('203.0.113.4:8080')).toBe('203.0.113.4')
|
|
86
|
+
expect(normalizeIpForBucketKey('203.0.113.4:0')).toBe('203.0.113.4')
|
|
87
|
+
expect(normalizeIpForBucketKey(' 203.0.113.4:65535 ')).toBe('203.0.113.4')
|
|
88
|
+
// Not a port (6 digits) → not stripped, and the remainder is not an address.
|
|
89
|
+
expect(normalizeIpForBucketKey('203.0.113.4:123456')).toBeNull()
|
|
90
|
+
// Out-of-range head is still rejected once the port comes off.
|
|
91
|
+
expect(normalizeIpForBucketKey('203.0.113.256:8080')).toBeNull()
|
|
92
|
+
// Unbracketed IPv6 + bare port is genuinely ambiguous (`:1` is equally a
|
|
93
|
+
// final group), so the IPv6 reading wins and nothing is stripped.
|
|
94
|
+
expect(normalizeIpForBucketKey('2001:db8::1')).toBe('2001:db8::1')
|
|
95
|
+
expect(normalizeIpForBucketKey('::1:443')).toBe('::1:443')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('rejects zero-padded IPv4 octets (one peer must not split two buckets)', () => {
|
|
99
|
+
expect(normalizeIpForBucketKey('203.0.113.04')).toBeNull()
|
|
100
|
+
expect(normalizeIpForBucketKey('203.0.113.004')).toBeNull()
|
|
101
|
+
expect(normalizeIpForBucketKey('203.00.113.4')).toBeNull()
|
|
102
|
+
expect(normalizeIpForBucketKey('203.0.113.04:8080')).toBeNull()
|
|
103
|
+
expect(normalizeIpForBucketKey('::ffff:203.0.113.04')).toBeNull()
|
|
104
|
+
// A lone `0` octet is not padding.
|
|
105
|
+
expect(normalizeIpForBucketKey('203.0.113.0')).toBe('203.0.113.0')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('rejects a dangling separator and a misplaced embedded IPv4', () => {
|
|
109
|
+
expect(normalizeIpForBucketKey('2001:db8::1:')).toBeNull()
|
|
110
|
+
expect(normalizeIpForBucketKey(':2001:db8::1')).toBeNull()
|
|
111
|
+
expect(normalizeIpForBucketKey('1.2.3.4::1')).toBeNull()
|
|
112
|
+
expect(normalizeIpForBucketKey('::1.2.3.4:5')).toBeNull()
|
|
113
|
+
// The legitimate shapes still pass.
|
|
114
|
+
expect(normalizeIpForBucketKey('::')).toBe('::')
|
|
115
|
+
expect(normalizeIpForBucketKey('2001:db8::')).toBe('2001:db8::')
|
|
116
|
+
expect(normalizeIpForBucketKey('::1.2.3.4')).toBe('::1.2.3.4')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('rejects oversized input rather than making an unbounded key', () => {
|
|
120
|
+
const oversized = 'a'.repeat(IP_BUCKET_KEY_MAX_LENGTH + 1)
|
|
121
|
+
expect(oversized.length).toBeGreaterThan(IP_BUCKET_KEY_MAX_LENGTH)
|
|
122
|
+
expect(normalizeIpForBucketKey(oversized)).toBeNull()
|
|
123
|
+
// A valid address padded past the cap is still refused.
|
|
124
|
+
expect(normalizeIpForBucketKey(`203.0.113.4${' '.repeat(200)}`)).toBe('203.0.113.4')
|
|
125
|
+
expect(normalizeIpForBucketKey(`${'0'.repeat(70)}.1.1.1`)).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('rejects junk', () => {
|
|
129
|
+
expect(normalizeIpForBucketKey('')).toBeNull()
|
|
130
|
+
expect(normalizeIpForBucketKey(' ')).toBeNull()
|
|
131
|
+
expect(normalizeIpForBucketKey('unknown')).toBeNull()
|
|
132
|
+
expect(normalizeIpForBucketKey('example.com')).toBeNull()
|
|
133
|
+
expect(normalizeIpForBucketKey('203.0.113.4, 198.51.100.7')).toBeNull()
|
|
134
|
+
expect(normalizeIpForBucketKey(undefined as unknown as string)).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GOLDEN CHARACTERIZATION TESTS — `decodeNatsChunk`.
|
|
3
|
+
*
|
|
4
|
+
* Pins the NATS chunk → `ChatStreamEvent` mapping for the whole recorded
|
|
5
|
+
* corpus of wire shapes (every MESSAGE_TYPE the decoder handles, plus the
|
|
6
|
+
* malformed/unknown tolerance cases). Retargeted from the deleted
|
|
7
|
+
* `parseChunkToAction` golden suite when the superseded legacy parser was
|
|
8
|
+
* removed — same corpus, now snapshotted against the ONE live decoder.
|
|
9
|
+
*
|
|
10
|
+
* Do NOT "fix" behaviors captured here — they are the recorded baseline.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import { decodeNatsChunk } from '../nats-decoder'
|
|
15
|
+
|
|
16
|
+
/** Recorded corpus of realistic NATS chunk shapes, keyed by scenario name. */
|
|
17
|
+
const CORPUS: Record<string, unknown> = {
|
|
18
|
+
message_start: { type: 'MESSAGE_START' },
|
|
19
|
+
message_end: { type: 'MESSAGE_END' },
|
|
20
|
+
|
|
21
|
+
text: { type: 'TEXT', text: 'Hello, ' },
|
|
22
|
+
text_empty_string: { type: 'TEXT', text: '' },
|
|
23
|
+
text_missing_text_field: { type: 'TEXT' },
|
|
24
|
+
|
|
25
|
+
thinking: { type: 'THINKING', text: 'Considering options…' },
|
|
26
|
+
thinking_missing_text: { type: 'THINKING' },
|
|
27
|
+
|
|
28
|
+
guide: { type: 'GUIDE', text: '## Enroll a device\n1. Open Settings' },
|
|
29
|
+
guide_empty_string: { type: 'GUIDE', text: '' },
|
|
30
|
+
guide_missing_text: { type: 'GUIDE' },
|
|
31
|
+
|
|
32
|
+
ai_metadata_full: {
|
|
33
|
+
type: 'AI_METADATA',
|
|
34
|
+
modelDisplayName: 'Claude Sonnet',
|
|
35
|
+
modelName: 'claude-sonnet-x',
|
|
36
|
+
providerName: 'anthropic',
|
|
37
|
+
contextWindow: 200000,
|
|
38
|
+
},
|
|
39
|
+
// Legacy field name `provider` (not `providerName`) is accepted.
|
|
40
|
+
ai_metadata_legacy_provider_field: {
|
|
41
|
+
type: 'AI_METADATA',
|
|
42
|
+
modelName: 'gpt-x',
|
|
43
|
+
provider: 'openai',
|
|
44
|
+
},
|
|
45
|
+
ai_metadata_missing_model: { type: 'AI_METADATA', providerName: 'anthropic' },
|
|
46
|
+
ai_metadata_non_numeric_context_window: {
|
|
47
|
+
type: 'AI_METADATA',
|
|
48
|
+
modelName: 'claude-sonnet-x',
|
|
49
|
+
providerName: 'anthropic',
|
|
50
|
+
contextWindow: '200000',
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
executing_tool: {
|
|
54
|
+
type: 'EXECUTING_TOOL',
|
|
55
|
+
integratedToolType: 'TACTICAL_RMM',
|
|
56
|
+
toolFunction: 'run_script',
|
|
57
|
+
title: 'Run cleanup script',
|
|
58
|
+
// The card's body line. Carried by EXECUTING only — the EXECUTED chunk
|
|
59
|
+
// never repeats it, so losing it here blanks the tool card for the whole
|
|
60
|
+
// run. Every chunk in this corpus omitted it once, which is how a decoder
|
|
61
|
+
// that silently dropped the field shipped with the goldens still green.
|
|
62
|
+
toolExplanation: 'Discovering columns available in the system_info table for accurate querying.',
|
|
63
|
+
parameters: { script: 'cleanup.sh', timeout: 30 },
|
|
64
|
+
toolExecutionRequestId: 'exec-1',
|
|
65
|
+
},
|
|
66
|
+
executing_tool_minimal: { type: 'EXECUTING_TOOL' },
|
|
67
|
+
|
|
68
|
+
executed_tool: {
|
|
69
|
+
type: 'EXECUTED_TOOL',
|
|
70
|
+
integratedToolType: 'TACTICAL_RMM',
|
|
71
|
+
toolFunction: 'run_script',
|
|
72
|
+
parameters: { script: 'cleanup.sh' },
|
|
73
|
+
result: 'Freed 2.3 GB',
|
|
74
|
+
success: true,
|
|
75
|
+
toolExecutionRequestId: 'exec-1',
|
|
76
|
+
},
|
|
77
|
+
executed_tool_failure_no_exec_id: {
|
|
78
|
+
type: 'EXECUTED_TOOL',
|
|
79
|
+
integratedToolType: 'FLEET_MDM',
|
|
80
|
+
toolFunction: 'lock_device',
|
|
81
|
+
result: 'Device unreachable',
|
|
82
|
+
success: false,
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
approval_request_single: {
|
|
86
|
+
type: 'APPROVAL_REQUEST',
|
|
87
|
+
approvalRequestId: 'req-1',
|
|
88
|
+
approvalType: 'CLIENT',
|
|
89
|
+
command: 'systemctl restart nats',
|
|
90
|
+
explanation: 'Restart the message broker',
|
|
91
|
+
},
|
|
92
|
+
// snake_case id field (approval_request_id) is accepted as a fallback.
|
|
93
|
+
approval_request_snake_case_id: {
|
|
94
|
+
type: 'APPROVAL_REQUEST',
|
|
95
|
+
approval_request_id: 'req-2',
|
|
96
|
+
command: 'rm -rf /tmp/cache',
|
|
97
|
+
},
|
|
98
|
+
approval_request_batch: {
|
|
99
|
+
type: 'APPROVAL_REQUEST',
|
|
100
|
+
approvalRequestId: 'batch-1',
|
|
101
|
+
approvalType: 'ADMIN',
|
|
102
|
+
toolCalls: [
|
|
103
|
+
{
|
|
104
|
+
toolExecutionRequestId: 'exec-a',
|
|
105
|
+
toolName: 'create_ticket',
|
|
106
|
+
toolTitle: 'Create ticket',
|
|
107
|
+
toolExplanation: 'Opens a new support ticket',
|
|
108
|
+
toolType: 'WRITE',
|
|
109
|
+
requiresApproval: true,
|
|
110
|
+
approvalType: 'ADMIN',
|
|
111
|
+
toolCallArguments: { subject: 'Printer down' },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
toolExecutionRequestId: 'exec-b',
|
|
115
|
+
toolName: 'read_kb',
|
|
116
|
+
requiresApproval: false,
|
|
117
|
+
approvalType: null,
|
|
118
|
+
toolCallArguments: null,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
// Malformed entries inside toolCalls are coerced/filtered by normalizeToolCalls.
|
|
123
|
+
approval_request_batch_malformed_entries: {
|
|
124
|
+
type: 'APPROVAL_REQUEST',
|
|
125
|
+
approvalRequestId: 'batch-2',
|
|
126
|
+
toolCalls: [
|
|
127
|
+
null,
|
|
128
|
+
'not-an-object',
|
|
129
|
+
{ toolName: 42, requiresApproval: 'yes', toolCallArguments: 'nope' },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
// CHARACTERIZATION: an empty toolCalls array falls back to the SINGLE
|
|
133
|
+
// approval-request shape (batch requires length > 0).
|
|
134
|
+
approval_request_empty_tool_calls: {
|
|
135
|
+
type: 'APPROVAL_REQUEST',
|
|
136
|
+
approvalRequestId: 'req-3',
|
|
137
|
+
toolCalls: [],
|
|
138
|
+
command: 'echo hi',
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
approval_result_approved: {
|
|
142
|
+
type: 'APPROVAL_RESULT',
|
|
143
|
+
approvalRequestId: 'req-1',
|
|
144
|
+
approved: true,
|
|
145
|
+
approvalType: 'CLIENT',
|
|
146
|
+
displayName: 'Jane Admin',
|
|
147
|
+
},
|
|
148
|
+
approval_result_rejected_resolved_by_name: {
|
|
149
|
+
type: 'APPROVAL_RESULT',
|
|
150
|
+
approvalRequestId: 'req-1',
|
|
151
|
+
approved: false,
|
|
152
|
+
resolvedByName: 'Bob Tech',
|
|
153
|
+
},
|
|
154
|
+
// CHARACTERIZATION: approved must be === true; any other value → rejected.
|
|
155
|
+
approval_result_truthy_string_approved: {
|
|
156
|
+
type: 'APPROVAL_RESULT',
|
|
157
|
+
approvalRequestId: 'req-9',
|
|
158
|
+
approved: 'true',
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
error_full: { type: 'ERROR', error: 'Agent crashed', details: '{"error":{"message":"boom"}}' },
|
|
162
|
+
error_bare: { type: 'ERROR' },
|
|
163
|
+
|
|
164
|
+
message_request: {
|
|
165
|
+
type: 'MESSAGE_REQUEST',
|
|
166
|
+
text: 'open a ticket',
|
|
167
|
+
ownerType: 'CLIENT',
|
|
168
|
+
displayName: 'Alice',
|
|
169
|
+
userId: 'user-42',
|
|
170
|
+
},
|
|
171
|
+
message_request_with_context_items: {
|
|
172
|
+
type: 'MESSAGE_REQUEST',
|
|
173
|
+
text: 'diagnose this device',
|
|
174
|
+
contextItems: [
|
|
175
|
+
{ type: 'device', id: 'dev-1' },
|
|
176
|
+
{ type: 'ticket' }, // missing id → filtered
|
|
177
|
+
{ id: 'orphan' }, // missing type → filtered
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
message_request_no_text: { type: 'MESSAGE_REQUEST' },
|
|
181
|
+
|
|
182
|
+
token_usage: {
|
|
183
|
+
type: 'TOKEN_USAGE',
|
|
184
|
+
inputTokensSize: 1200,
|
|
185
|
+
outputTokensSize: 340,
|
|
186
|
+
totalTokensSize: 1540,
|
|
187
|
+
contextSize: 180000,
|
|
188
|
+
},
|
|
189
|
+
token_usage_empty_defaults: { type: 'TOKEN_USAGE' },
|
|
190
|
+
|
|
191
|
+
context_compaction_start: { type: 'CONTEXT_COMPACTION_START' },
|
|
192
|
+
context_compaction_end: { type: 'CONTEXT_COMPACTION_END', text: 'Compacted 34 messages' },
|
|
193
|
+
context_compaction_end_no_summary: { type: 'CONTEXT_COMPACTION_END' },
|
|
194
|
+
|
|
195
|
+
system: { type: 'SYSTEM', text: 'Technician joined the chat' },
|
|
196
|
+
system_no_text: { type: 'SYSTEM' },
|
|
197
|
+
|
|
198
|
+
direct_message: {
|
|
199
|
+
type: 'DIRECT_MESSAGE',
|
|
200
|
+
text: 'Hi, human here taking over',
|
|
201
|
+
ownerType: 'ADMIN',
|
|
202
|
+
displayName: 'Jane Admin',
|
|
203
|
+
userId: 'admin-7',
|
|
204
|
+
},
|
|
205
|
+
direct_message_no_text: { type: 'DIRECT_MESSAGE' },
|
|
206
|
+
|
|
207
|
+
dialog_closed: { type: 'DIALOG_CLOSED' },
|
|
208
|
+
|
|
209
|
+
// ---- Malformed / unknown chunks ------------------------------------
|
|
210
|
+
unknown_type: { type: 'SOMETHING_NEW' },
|
|
211
|
+
missing_type: { text: 'hello' },
|
|
212
|
+
null_chunk: null,
|
|
213
|
+
undefined_chunk: undefined,
|
|
214
|
+
string_chunk: 'TEXT',
|
|
215
|
+
number_chunk: 42,
|
|
216
|
+
empty_object: {},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
describe('decodeNatsChunk — golden corpus', () => {
|
|
220
|
+
it('maps the full recorded corpus to normalized events (snapshot)', () => {
|
|
221
|
+
const results = Object.fromEntries(
|
|
222
|
+
Object.entries(CORPUS).map(([name, chunk]) => [name, decodeNatsChunk(chunk)]),
|
|
223
|
+
)
|
|
224
|
+
expect(results).toMatchSnapshot()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('decodeNatsChunk — seq envelope', () => {
|
|
229
|
+
it('lifts a numeric JetStream `streamSeq` into `seq`, and omits it otherwise (snapshot)', () => {
|
|
230
|
+
expect({
|
|
231
|
+
numeric: decodeNatsChunk({ type: 'TEXT', text: 'hi', streamSeq: 42 }),
|
|
232
|
+
absent: decodeNatsChunk({ type: 'TEXT', text: 'hi' }),
|
|
233
|
+
non_numeric: decodeNatsChunk({ type: 'TEXT', text: 'hi', streamSeq: '42' }),
|
|
234
|
+
zero: decodeNatsChunk({ type: 'MESSAGE_START', streamSeq: 0 }),
|
|
235
|
+
}).toMatchSnapshot()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE wire-protocol decoder — a timer-free synchronous state machine,
|
|
3
|
+
* mechanically extracted from `useSseChatAdapter`'s inline
|
|
4
|
+
* `createDocStreamFn` parser. Byte-for-byte behavior parity with the
|
|
5
|
+
* legacy parser is the contract (the golden fixtures in
|
|
6
|
+
* `src/components/chat/hooks/__tests__/sse-stream-golden.test.ts` pin it
|
|
7
|
+
* through the full hook path), including its quirks:
|
|
8
|
+
*
|
|
9
|
+
* - A leading `\0`-terminated block that fails JSON.parse flips the
|
|
10
|
+
* stream into text mode and the WHOLE buffer (including the `\0`)
|
|
11
|
+
* is emitted as answer text (legacy no-frame stream fallback). This
|
|
12
|
+
* transition is marked `turn-start { implicit: true }`.
|
|
13
|
+
* - In text mode only `\x1F` is scanned — literal `\0` / `\x1E` bytes
|
|
14
|
+
* pass through into the answer; the FIRST literal `\x1F` flips into
|
|
15
|
+
* trailer mode and everything after is captured as the trailer
|
|
16
|
+
* (golden fixture (d) characterizes this mis-framing; the fix lives
|
|
17
|
+
* in `encode.ts`'s sentinel stripping, NOT here).
|
|
18
|
+
* - The `\x1E` sentinel and the `\x1F` trailer can arrive in ONE TCP
|
|
19
|
+
* chunk (fixed-answer responses) — the post-sentinel slice is
|
|
20
|
+
* re-scanned for the trailer sentinel.
|
|
21
|
+
* - Multi-byte UTF-8 across chunk boundaries survives via a single
|
|
22
|
+
* streaming TextDecoder (`{ stream: true }` on every push; no final
|
|
23
|
+
* flush — a trailing partial code point is dropped, as legacy did).
|
|
24
|
+
* - `end()` parses the accumulated trailer (malformed → silently
|
|
25
|
+
* ignored) and drops any un-terminated leading buffer, as legacy did.
|
|
26
|
+
* It is IDEMPOTENT (a deliberate deviation from legacy): repeat calls
|
|
27
|
+
* emit nothing, so an adapter that ends in both its completion path
|
|
28
|
+
* and its `finally` cannot double-count the usage frame.
|
|
29
|
+
*
|
|
30
|
+
* State flow: leading → (sentinel | parse-failure) → text → (\x1F) →
|
|
31
|
+
* trailer-accumulate → end().
|
|
32
|
+
*
|
|
33
|
+
* Server-safe: no React, no timers, TextDecoder only.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { FRAME_TERMINATOR, END_OF_LEADING, TRAILER_SENTINEL } from './frames'
|
|
37
|
+
import type { ChatStreamEvent } from './events'
|
|
38
|
+
|
|
39
|
+
export interface SseFrameDecoder {
|
|
40
|
+
/** Feed raw response bytes; returns the events they produced. */
|
|
41
|
+
push(bytes: Uint8Array): ChatStreamEvent[]
|
|
42
|
+
/**
|
|
43
|
+
* Signal end-of-stream; parses the trailing usage frame if present.
|
|
44
|
+
* IDEMPOTENT — every call after the first returns `[]`. Adapters
|
|
45
|
+
* routinely call this from BOTH their completion path and a `finally`,
|
|
46
|
+
* and a re-emitted `usage`/`stage:'end'` event would double-count token
|
|
47
|
+
* usage (displayed cost doubles).
|
|
48
|
+
*/
|
|
49
|
+
end(): ChatStreamEvent[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escape `<` so markdown renderers that pass HTML through (rehypeRaw)
|
|
54
|
+
* don't treat XML-like tokens in Claude's thinking output as elements.
|
|
55
|
+
* `<` → `<` preserves the visible character without breaking
|
|
56
|
+
* blockquote `>` markers. Per-character, so it distributes over
|
|
57
|
+
* concatenation: escape(a + b) === escape(a) + escape(b) — callers may
|
|
58
|
+
* apply it per-delta or on the accumulated string interchangeably.
|
|
59
|
+
*/
|
|
60
|
+
export function escapeThinkingTags(text: string): string {
|
|
61
|
+
return text.replace(/</g, '<')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map one parsed leading frame to normalized events, replicating the
|
|
66
|
+
* legacy parser's else-if chain ORDER and its exact truthiness/typeof
|
|
67
|
+
* gates. Frames that matched a branch but failed its inner validation
|
|
68
|
+
* (e.g. `routing` without a string `routedComplexity`) produce NO event,
|
|
69
|
+
* exactly like the legacy no-op.
|
|
70
|
+
*/
|
|
71
|
+
function mapLeadingFrame(meta: any, out: ChatStreamEvent[]): void {
|
|
72
|
+
if (meta.status === 'thinking') {
|
|
73
|
+
out.push({ type: 'status', phase: 'thinking' })
|
|
74
|
+
} else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
|
|
75
|
+
// Wire is ALREADY delta — emit verbatim, append-only contract.
|
|
76
|
+
out.push({ type: 'thinking-delta', text: meta.text })
|
|
77
|
+
} else if (meta.kind === 'usage' && meta.stage === 'start') {
|
|
78
|
+
out.push({
|
|
79
|
+
type: 'usage',
|
|
80
|
+
stage: 'start',
|
|
81
|
+
input_tokens: meta.input_tokens,
|
|
82
|
+
cache_read_input_tokens: meta.cache_read_input_tokens,
|
|
83
|
+
cache_creation_input_tokens: meta.cache_creation_input_tokens,
|
|
84
|
+
})
|
|
85
|
+
} else if (meta.kind === 'decision_resolved' && typeof meta.action === 'string') {
|
|
86
|
+
const status = meta.action === 'rejected' ? 'rejected' : 'approved'
|
|
87
|
+
const toolName = typeof meta.tool_name === 'string' ? meta.tool_name : undefined
|
|
88
|
+
const result = meta.result ?? null
|
|
89
|
+
const card = meta.card ?? null
|
|
90
|
+
out.push({
|
|
91
|
+
type: 'approval-resolved',
|
|
92
|
+
status,
|
|
93
|
+
ok: meta.ok === true,
|
|
94
|
+
willAutoContinue: meta.willAutoContinue === true,
|
|
95
|
+
...(toolName ? { toolName } : {}),
|
|
96
|
+
...(result ? { result } : {}),
|
|
97
|
+
...(card?.marker ? { marker: card.marker } : {}),
|
|
98
|
+
...(card?.ref ? { cardRef: card.ref } : {}),
|
|
99
|
+
...(card?.type ? { cardType: card.type } : {}),
|
|
100
|
+
...(typeof meta.receiptText === 'string' ? { receiptText: meta.receiptText } : {}),
|
|
101
|
+
requestId: typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
|
|
102
|
+
})
|
|
103
|
+
} else if (meta.kind === 'approval_request' && meta.proposalId) {
|
|
104
|
+
const proposalId = String(meta.proposalId)
|
|
105
|
+
const toolName = String(meta.toolName ?? 'tool')
|
|
106
|
+
const headline =
|
|
107
|
+
typeof meta.title === 'string' && meta.title.length > 0 ? meta.title : toolName
|
|
108
|
+
const rawFields = Array.isArray(meta.fields)
|
|
109
|
+
? (meta.fields as Array<{ label?: string; value?: string }>)
|
|
110
|
+
: []
|
|
111
|
+
const fields: Array<{ label: string; value: string }> = []
|
|
112
|
+
for (const f of rawFields) {
|
|
113
|
+
if (!f || !f.label || !f.value) continue
|
|
114
|
+
fields.push({ label: f.label, value: f.value })
|
|
115
|
+
}
|
|
116
|
+
out.push({
|
|
117
|
+
type: 'approval-request',
|
|
118
|
+
requestId: proposalId,
|
|
119
|
+
approvalType: toolName,
|
|
120
|
+
command: headline,
|
|
121
|
+
fields,
|
|
122
|
+
status: 'pending',
|
|
123
|
+
})
|
|
124
|
+
} else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
|
|
125
|
+
out.push({ type: 'text-delta', text: meta.text, leading: true })
|
|
126
|
+
} else if (meta.kind === 'tool_error') {
|
|
127
|
+
const msg =
|
|
128
|
+
typeof meta.message === 'string' && meta.message.length > 0
|
|
129
|
+
? meta.message
|
|
130
|
+
: 'Could not complete the requested action right now.'
|
|
131
|
+
out.push({ type: 'error', title: msg })
|
|
132
|
+
} else if (meta.kind === 'routing') {
|
|
133
|
+
if (typeof meta.routedComplexity === 'string') {
|
|
134
|
+
out.push({
|
|
135
|
+
type: 'metadata',
|
|
136
|
+
routing: {
|
|
137
|
+
routedComplexity: meta.routedComplexity,
|
|
138
|
+
...(typeof meta.routedModel === 'string' ? { routedModel: meta.routedModel } : {}),
|
|
139
|
+
routedThinkingBudget:
|
|
140
|
+
typeof meta.routedThinkingBudget === 'number' ? meta.routedThinkingBudget : null,
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// Catch-all metadata-ish frame. Raw values pass through UNVALIDATED
|
|
146
|
+
// (possibly undefined) so the consumer can replicate the legacy
|
|
147
|
+
// presence/truthiness gates exactly — including the `model`-presence
|
|
148
|
+
// trigger whose value is never stored.
|
|
149
|
+
out.push({
|
|
150
|
+
type: 'metadata',
|
|
151
|
+
sources: meta.sources,
|
|
152
|
+
refs: meta.refs,
|
|
153
|
+
provider: meta.provider,
|
|
154
|
+
modelLabel: meta.modelLabel,
|
|
155
|
+
modelName: meta.model,
|
|
156
|
+
contextWindowMaxTokens: meta.contextWindowMaxTokens,
|
|
157
|
+
scrollAnchor: meta.scrollAnchor,
|
|
158
|
+
conversationId: meta.conversationId,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createSseFrameDecoder(): SseFrameDecoder {
|
|
164
|
+
const textDecoder = new TextDecoder()
|
|
165
|
+
let buffer = ''
|
|
166
|
+
let inText = false
|
|
167
|
+
let inTrailer = false
|
|
168
|
+
let trailerBuffer = ''
|
|
169
|
+
let ended = false
|
|
170
|
+
|
|
171
|
+
function push(bytes: Uint8Array): ChatStreamEvent[] {
|
|
172
|
+
const out: ChatStreamEvent[] = []
|
|
173
|
+
const chunk = textDecoder.decode(bytes, { stream: true })
|
|
174
|
+
|
|
175
|
+
if (!inText) {
|
|
176
|
+
buffer += chunk
|
|
177
|
+
while (!inText) {
|
|
178
|
+
const recIdx = buffer.indexOf(END_OF_LEADING)
|
|
179
|
+
const nullIdx = buffer.indexOf(FRAME_TERMINATOR)
|
|
180
|
+
if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
|
|
181
|
+
inText = true
|
|
182
|
+
out.push({ type: 'turn-start' })
|
|
183
|
+
const after = buffer.slice(recIdx + 1)
|
|
184
|
+
buffer = ''
|
|
185
|
+
if (after) {
|
|
186
|
+
// The `after` slice may ALSO contain the `\x1F` trailing-
|
|
187
|
+
// usage sentinel — common for fixed-answer responses where
|
|
188
|
+
// the whole frame sequence arrives in ONE TCP chunk.
|
|
189
|
+
const unitIdx = after.indexOf(TRAILER_SENTINEL)
|
|
190
|
+
if (unitIdx === -1) {
|
|
191
|
+
out.push({ type: 'text-delta', text: after })
|
|
192
|
+
} else {
|
|
193
|
+
const textBefore = after.slice(0, unitIdx)
|
|
194
|
+
const trailerAfter = after.slice(unitIdx + 1)
|
|
195
|
+
if (textBefore) {
|
|
196
|
+
out.push({ type: 'text-delta', text: textBefore })
|
|
197
|
+
}
|
|
198
|
+
inTrailer = true
|
|
199
|
+
trailerBuffer = trailerAfter
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
if (nullIdx === -1) break // need more bytes
|
|
205
|
+
const metaStr = buffer.slice(0, nullIdx)
|
|
206
|
+
const remaining = buffer.slice(nullIdx + 1)
|
|
207
|
+
let meta: any
|
|
208
|
+
try {
|
|
209
|
+
meta = JSON.parse(metaStr)
|
|
210
|
+
} catch {
|
|
211
|
+
// Not JSON — start of answer body. The WHOLE buffer (including
|
|
212
|
+
// the `\0`) becomes answer text; `implicit` tells consumers
|
|
213
|
+
// this was the fallback path, not the `\x1E` sentinel.
|
|
214
|
+
inText = true
|
|
215
|
+
out.push({ type: 'turn-start', implicit: true })
|
|
216
|
+
if (buffer.length > 0) {
|
|
217
|
+
out.push({ type: 'text-delta', text: buffer })
|
|
218
|
+
buffer = ''
|
|
219
|
+
}
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
mapLeadingFrame(meta, out)
|
|
223
|
+
buffer = remaining
|
|
224
|
+
}
|
|
225
|
+
} else if (inTrailer) {
|
|
226
|
+
trailerBuffer += chunk
|
|
227
|
+
} else {
|
|
228
|
+
// Text mode: only the `\x1F` trailer sentinel is scanned. The
|
|
229
|
+
// unconditional emit (even for an empty decode of a partial
|
|
230
|
+
// multi-byte code point) mirrors the legacy per-chunk yield.
|
|
231
|
+
const sepIdx = chunk.indexOf(TRAILER_SENTINEL)
|
|
232
|
+
if (sepIdx === -1) {
|
|
233
|
+
out.push({ type: 'text-delta', text: chunk })
|
|
234
|
+
} else {
|
|
235
|
+
const before = chunk.slice(0, sepIdx)
|
|
236
|
+
const after = chunk.slice(sepIdx + 1)
|
|
237
|
+
if (before) out.push({ type: 'text-delta', text: before })
|
|
238
|
+
inTrailer = true
|
|
239
|
+
trailerBuffer = after
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function end(): ChatStreamEvent[] {
|
|
246
|
+
// Idempotency guard: a second end() must emit NOTHING. The trailer is
|
|
247
|
+
// also cleared so no later push()/end() pair can replay it.
|
|
248
|
+
if (ended) return []
|
|
249
|
+
ended = true
|
|
250
|
+
const out: ChatStreamEvent[] = []
|
|
251
|
+
if (trailerBuffer.length > 0) {
|
|
252
|
+
const raw = trailerBuffer
|
|
253
|
+
trailerBuffer = ''
|
|
254
|
+
try {
|
|
255
|
+
const meta = JSON.parse(raw)
|
|
256
|
+
if (meta.kind === 'usage' && (meta.stage === 'end' || meta.stage === 'display')) {
|
|
257
|
+
out.push({
|
|
258
|
+
type: 'usage',
|
|
259
|
+
stage: 'end',
|
|
260
|
+
input_tokens: meta.input_tokens,
|
|
261
|
+
output_tokens: meta.output_tokens,
|
|
262
|
+
cache_read_input_tokens: meta.cache_read_input_tokens,
|
|
263
|
+
cache_creation_input_tokens: meta.cache_creation_input_tokens,
|
|
264
|
+
hit_rate_pct: meta.hit_rate_pct,
|
|
265
|
+
telemetry: meta.telemetry,
|
|
266
|
+
breakdown: meta.breakdown,
|
|
267
|
+
debug: meta.debug,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Malformed trailer — silently ignore (legacy parity).
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { push, end }
|
|
278
|
+
}
|