@flamingo-stack/openframe-frontend-core 0.0.490-1506.3944.1 → 0.0.490
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/{chunk-5CJR2GZ4.cjs → chunk-2MKUHJ3O.cjs} +31 -31
- package/dist/{chunk-5CJR2GZ4.cjs.map → chunk-2MKUHJ3O.cjs.map} +1 -1
- package/dist/{chunk-S2NHJDKQ.cjs → chunk-3IEVA5AO.cjs} +62 -62
- package/dist/{chunk-S2NHJDKQ.cjs.map → chunk-3IEVA5AO.cjs.map} +1 -1
- package/dist/{chunk-GTWO5NAY.js → chunk-6CFFISUC.js} +2 -2
- package/dist/{chunk-VHG6AX5V.js → chunk-6CJ2K7BD.js} +2 -2
- package/dist/{chunk-4NWARV5I.js → chunk-6QK3ONDD.js} +2 -2
- package/dist/{chunk-YHOIAA2E.cjs → chunk-7I42V2OD.cjs} +60 -60
- package/dist/chunk-7I42V2OD.cjs.map +1 -0
- package/dist/{chunk-T2EGSZFN.js → chunk-AIRNG27G.js} +5 -6
- package/dist/chunk-AIRNG27G.js.map +1 -0
- package/dist/{chunk-NWX7MMF5.cjs → chunk-AW63ET2A.cjs} +15 -15
- package/dist/{chunk-NWX7MMF5.cjs.map → chunk-AW63ET2A.cjs.map} +1 -1
- package/dist/{chunk-TKN5667N.js → chunk-BZMOEWWS.js} +10 -10
- package/dist/chunk-BZMOEWWS.js.map +1 -0
- package/dist/{chunk-DSDZ5T4P.cjs → chunk-D5LQMRPU.cjs} +5 -5
- package/dist/{chunk-DSDZ5T4P.cjs.map → chunk-D5LQMRPU.cjs.map} +1 -1
- package/dist/{chunk-CORIBKHG.js → chunk-EGXCWZYP.js} +2 -2
- package/dist/{chunk-5EYRXSY5.cjs → chunk-EJQUYZXF.cjs} +9 -9
- package/dist/{chunk-5EYRXSY5.cjs.map → chunk-EJQUYZXF.cjs.map} +1 -1
- package/dist/{chunk-P4WTZU6L.cjs → chunk-FY5XAW3H.cjs} +11 -11
- package/dist/{chunk-P4WTZU6L.cjs.map → chunk-FY5XAW3H.cjs.map} +1 -1
- package/dist/{chunk-VQ523ORK.cjs → chunk-GMPYR3MX.cjs} +26 -26
- package/dist/{chunk-VQ523ORK.cjs.map → chunk-GMPYR3MX.cjs.map} +1 -1
- package/dist/{chunk-QSCHCZL3.cjs → chunk-GUTYUMGV.cjs} +7 -7
- package/dist/{chunk-QSCHCZL3.cjs.map → chunk-GUTYUMGV.cjs.map} +1 -1
- package/dist/{chunk-Q6E7VJ4W.cjs → chunk-IEZ3YBAP.cjs} +27 -28
- package/dist/chunk-IEZ3YBAP.cjs.map +1 -0
- package/dist/{chunk-HSACATCD.js → chunk-MPYDX5WX.js} +2 -2
- package/dist/{chunk-RQLBHKXN.js → chunk-NGEFYPY7.js} +2 -2
- package/dist/{chunk-BE6DK7QY.js → chunk-OQIMH2WQ.js} +8985 -11081
- package/dist/chunk-OQIMH2WQ.js.map +1 -0
- package/dist/{chunk-CSYSYJCF.cjs → chunk-UZZT65PQ.cjs} +37 -37
- package/dist/{chunk-CSYSYJCF.cjs.map → chunk-UZZT65PQ.cjs.map} +1 -1
- package/dist/{chunk-YZ2OIYQ3.js → chunk-VDZO6IDR.js} +4 -4
- package/dist/{chunk-UUYRJPGJ.js → chunk-VL2YMDPH.js} +4 -4
- package/dist/{chunk-5LMGFI4J.cjs → chunk-VUXNXWOQ.cjs} +4946 -7042
- package/dist/chunk-VUXNXWOQ.cjs.map +1 -0
- package/dist/{chunk-XQ2B74SI.js → chunk-WLM3F3WU.js} +2 -2
- 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/guide-display.d.ts +14 -0
- package/dist/components/chat/guide-display.d.ts.map +1 -0
- package/dist/components/chat/hooks/index.d.ts +1 -0
- 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-nats-chat-adapter.d.ts +48 -15
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +57 -10
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -8
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -1
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +15 -21
- package/dist/components/chat/types/api.types.d.ts +81 -1
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +6 -0
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +15 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +93 -2
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
- package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +3 -2
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
- 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 +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +130 -170
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -63
- package/dist/components/index.js.map +1 -1
- 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 +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -42
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +2 -1
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +15 -55
- package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/index.cjs +2 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +15 -71
- package/dist/utils/index.cjs +40 -169
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +41 -162
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +1 -7
- package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
- package/src/components/chat/chat-message-enhanced.tsx +80 -163
- package/src/components/chat/chat-message-list.tsx +109 -358
- package/src/components/chat/embeddable-chat.tsx +0 -6
- package/src/components/chat/guide-display.tsx +44 -0
- package/src/components/chat/hooks/.index.md +33 -30
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +55 -36
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +77 -0
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-chat.ts +0 -8
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +825 -243
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +489 -0
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +684 -380
- package/src/components/chat/index.ts +1 -5
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +49 -39
- package/src/components/chat/types/.processing.types.md +58 -29
- package/src/components/chat/types/api.types.ts +73 -6
- package/src/components/chat/types/component.types.ts +9 -0
- package/src/components/chat/types/message.types.ts +17 -1
- package/src/components/chat/types/processing.types.ts +53 -11
- package/src/components/chat/utils/.chunk-parser.md +62 -0
- package/src/components/chat/utils/.index.md +40 -31
- package/src/components/chat/utils/chunk-parser.ts +262 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
- package/src/components/chat/utils/index.ts +8 -5
- package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
- package/src/components/chat/utils/process-historical-messages.ts +390 -352
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +4 -19
- package/src/components/ui/.simple-markdown-renderer.md +52 -0
- package/src/components/ui/index.ts +2 -1
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
- package/src/components/ui/simple-markdown-renderer.tsx +964 -0
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
- package/src/utils/index.ts +0 -1
- package/src/utils/markdown-section-extractor.ts +56 -41
- package/dist/chat-protocol/decode.d.ts +0 -58
- package/dist/chat-protocol/decode.d.ts.map +0 -1
- package/dist/chat-protocol/encode.d.ts +0 -44
- package/dist/chat-protocol/encode.d.ts.map +0 -1
- package/dist/chat-protocol/env-flag.d.ts +0 -35
- package/dist/chat-protocol/env-flag.d.ts.map +0 -1
- package/dist/chat-protocol/events.d.ts +0 -196
- package/dist/chat-protocol/events.d.ts.map +0 -1
- package/dist/chat-protocol/frames.d.ts +0 -213
- package/dist/chat-protocol/frames.d.ts.map +0 -1
- package/dist/chat-protocol/index.cjs +0 -494
- package/dist/chat-protocol/index.cjs.map +0 -1
- package/dist/chat-protocol/index.d.ts +0 -18
- package/dist/chat-protocol/index.d.ts.map +0 -1
- package/dist/chat-protocol/index.js +0 -478
- package/dist/chat-protocol/index.js.map +0 -1
- package/dist/chat-protocol/ip-normalize.d.ts +0 -44
- package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
- package/dist/chat-protocol/nats-decoder.d.ts +0 -26
- package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
- package/dist/chunk-5LMGFI4J.cjs.map +0 -1
- package/dist/chunk-BE6DK7QY.js.map +0 -1
- package/dist/chunk-Q6E7VJ4W.cjs.map +0 -1
- package/dist/chunk-T2EGSZFN.js.map +0 -1
- package/dist/chunk-TKN5667N.js.map +0 -1
- package/dist/chunk-YHOIAA2E.cjs.map +0 -1
- package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
- package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
- package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
- package/dist/components/chat/stream/index.d.ts +0 -17
- package/dist/components/chat/stream/index.d.ts.map +0 -1
- package/dist/components/chat/stream/message-mutations.d.ts +0 -77
- package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
- package/dist/components/ui/markdown/base-components.d.ts +0 -51
- package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
- package/dist/components/ui/markdown/engine.d.ts +0 -99
- package/dist/components/ui/markdown/engine.d.ts.map +0 -1
- package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
- package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
- package/dist/components/ui/markdown/index.d.ts +0 -18
- package/dist/components/ui/markdown/index.d.ts.map +0 -1
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
- package/dist/components/ui/markdown/sanitize.d.ts +0 -153
- package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/markdown/streaming.d.ts +0 -78
- package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
- package/dist/components/ui/markdown/text-size.d.ts +0 -34
- package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
- package/dist/utils/markdown-fences.d.ts +0 -42
- package/dist/utils/markdown-fences.d.ts.map +0 -1
- package/dist/utils/markdown-heading-id.d.ts +0 -85
- package/dist/utils/markdown-heading-id.d.ts.map +0 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -307
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -516
- package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -228
- package/src/chat-protocol/decode.ts +0 -278
- package/src/chat-protocol/encode.ts +0 -71
- package/src/chat-protocol/env-flag.ts +0 -39
- package/src/chat-protocol/events.ts +0 -232
- package/src/chat-protocol/frames.ts +0 -245
- package/src/chat-protocol/index.ts +0 -21
- package/src/chat-protocol/ip-normalize.ts +0 -146
- package/src/chat-protocol/nats-decoder.ts +0 -252
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -209
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +0 -37
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +0 -438
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -940
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -894
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
- package/src/components/chat/stream/chat-dialog-store.ts +0 -536
- package/src/components/chat/stream/chat-stream-reducer.ts +0 -1796
- package/src/components/chat/stream/delta-batcher.ts +0 -159
- package/src/components/chat/stream/index.ts +0 -55
- package/src/components/chat/stream/message-mutations.ts +0 -338
- package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -420
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -605
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -317
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
- package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
- package/src/components/ui/markdown/base-components.tsx +0 -360
- package/src/components/ui/markdown/engine.tsx +0 -315
- package/src/components/ui/markdown/heading-ids.ts +0 -239
- package/src/components/ui/markdown/index.ts +0 -49
- package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
- package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
- package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
- package/src/components/ui/markdown/sanitize.ts +0 -3499
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
- package/src/components/ui/markdown/streaming.ts +0 -362
- package/src/components/ui/markdown/text-size.ts +0 -106
- package/src/utils/markdown-fences.ts +0 -127
- package/src/utils/markdown-heading-id.ts +0 -348
- /package/dist/{chunk-GTWO5NAY.js.map → chunk-6CFFISUC.js.map} +0 -0
- /package/dist/{chunk-VHG6AX5V.js.map → chunk-6CJ2K7BD.js.map} +0 -0
- /package/dist/{chunk-4NWARV5I.js.map → chunk-6QK3ONDD.js.map} +0 -0
- /package/dist/{chunk-CORIBKHG.js.map → chunk-EGXCWZYP.js.map} +0 -0
- /package/dist/{chunk-HSACATCD.js.map → chunk-MPYDX5WX.js.map} +0 -0
- /package/dist/{chunk-RQLBHKXN.js.map → chunk-NGEFYPY7.js.map} +0 -0
- /package/dist/{chunk-YZ2OIYQ3.js.map → chunk-VDZO6IDR.js.map} +0 -0
- /package/dist/{chunk-UUYRJPGJ.js.map → chunk-VL2YMDPH.js.map} +0 -0
- /package/dist/{chunk-XQ2B74SI.js.map → chunk-WLM3F3WU.js.map} +0 -0
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `normalizeIpForBucketKey` — THE cross-repo normalizer for turning a raw
|
|
3
|
-
* client-IP candidate (`x-forwarded-for` hop, socket remote address, proxy
|
|
4
|
-
* header) into a stable rate-limit / dedup bucket key.
|
|
5
|
-
*
|
|
6
|
-
* WHY IT LIVES HERE: producer and consumer sit in DIFFERENT repos (the app
|
|
7
|
-
* emits the candidate, the hub buckets it) and each had grown its own
|
|
8
|
-
* normalizer. They disagreed — one unwrapped `::ffff:1.2.3.4` to the bare
|
|
9
|
-
* IPv4 and stripped `%zone`, the other kept the zone and only lower-cased the
|
|
10
|
-
* mapped form — so ONE visitor could land in TWO buckets depending on which
|
|
11
|
-
* side normalized. Both repos already depend on this package, and
|
|
12
|
-
* `./chat-protocol` is its server-safe subpath (no React, no browser APIs),
|
|
13
|
-
* so this is the one place both sides can share.
|
|
14
|
-
*
|
|
15
|
-
* Contract:
|
|
16
|
-
* - input longer than `IP_BUCKET_KEY_MAX_LENGTH` → `null` (an unbounded
|
|
17
|
-
* header must never become an unbounded map key);
|
|
18
|
-
* - `[…]` brackets and a trailing `]:port` are stripped, as is a bare
|
|
19
|
-
* `:port` on an IPv4 (`203.0.113.4:8080`) — an unbracketed IPv6 with a
|
|
20
|
-
* bare port stays ambiguous and is not stripped;
|
|
21
|
-
* - a `%zone` suffix is stripped (link-local scope is per-host, not part of
|
|
22
|
-
* the peer's identity);
|
|
23
|
-
* - IPv4-mapped IPv6 (`::ffff:203.0.113.4`) collapses to the bare IPv4, so
|
|
24
|
-
* the same peer buckets identically over either stack;
|
|
25
|
-
* - IPv6 is lower-cased;
|
|
26
|
-
* - IPv4 octets are range-checked and REJECTED when zero-padded (`.04`
|
|
27
|
-
* would otherwise bucket separately from `.4`), and IPv6 is
|
|
28
|
-
* charset/shape-checked (one `::` at most, ≤8 groups, an embedded IPv4
|
|
29
|
-
* only in the last group, no dangling separator); anything else returns
|
|
30
|
-
* `null` rather than a junk bucket key.
|
|
31
|
-
*
|
|
32
|
-
* It does NOT canonicalize IPv6 zero-compression (`2001:db8::1` vs
|
|
33
|
-
* `2001:0db8:0:0:0:0:0:1`) — peers do not spell their own address two ways
|
|
34
|
-
* within one deployment, and a full expander is more surface than the bucket
|
|
35
|
-
* key warrants.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
/** Longest accepted candidate. A full IPv6 + zone fits well inside this. */
|
|
39
|
-
export const IP_BUCKET_KEY_MAX_LENGTH = 64
|
|
40
|
-
|
|
41
|
-
function isIpv4(value: string): boolean {
|
|
42
|
-
const parts = value.split('.')
|
|
43
|
-
if (parts.length !== 4) return false
|
|
44
|
-
for (const part of parts) {
|
|
45
|
-
if (!/^\d{1,3}$/.test(part)) return false
|
|
46
|
-
// Leading zeros are a SECOND spelling of the same address (`203.0.113.04`),
|
|
47
|
-
// so accepting them verbatim splits one peer across two buckets. Reject
|
|
48
|
-
// rather than canonicalize: no legitimate emitter zero-pads, and some
|
|
49
|
-
// resolvers read a `0`-prefixed octet as octal, i.e. it is not even
|
|
50
|
-
// reliably the same address.
|
|
51
|
-
if (part.length > 1 && part.startsWith('0')) return false
|
|
52
|
-
if (Number(part) > 255) return false
|
|
53
|
-
}
|
|
54
|
-
return true
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isIpv6(value: string): boolean {
|
|
58
|
-
if (!value.includes(':')) return false
|
|
59
|
-
// Charset gate first: hex digits, separators, and the dots of a trailing
|
|
60
|
-
// embedded IPv4. Anything else (a hostname, a header injection) is out.
|
|
61
|
-
if (!/^[0-9a-f:.]+$/.test(value)) return false
|
|
62
|
-
if (value.includes(':::')) return false
|
|
63
|
-
// A dangling separator (`2001:db8::1:`) leaves an EMPTY trailing group that
|
|
64
|
-
// is not part of a `::` compression — malformed, not an address.
|
|
65
|
-
if (value.endsWith(':') && !value.endsWith('::')) return false
|
|
66
|
-
if (value.startsWith(':') && !value.startsWith('::')) return false
|
|
67
|
-
const compressions = value.split('::').length - 1
|
|
68
|
-
if (compressions > 1) return false
|
|
69
|
-
const groups = value.split(':')
|
|
70
|
-
if (groups.length > 8) return false
|
|
71
|
-
for (let i = 0; i < groups.length; i += 1) {
|
|
72
|
-
const group = groups[i]
|
|
73
|
-
if (group === '') continue
|
|
74
|
-
if (group.includes('.')) {
|
|
75
|
-
// An embedded IPv4 occupies the LAST two groups; anywhere else
|
|
76
|
-
// (`1.2.3.4::1`) it is malformed.
|
|
77
|
-
if (i !== groups.length - 1) return false
|
|
78
|
-
if (!isIpv4(group)) return false
|
|
79
|
-
continue
|
|
80
|
-
}
|
|
81
|
-
if (!/^[0-9a-f]{1,4}$/.test(group)) return false
|
|
82
|
-
}
|
|
83
|
-
// Uncompressed form must be exactly 8 groups (a trailing embedded IPv4
|
|
84
|
-
// occupies two, so 7 written groups is also complete).
|
|
85
|
-
if (compressions === 0) {
|
|
86
|
-
const embedsIpv4 = groups[groups.length - 1]?.includes('.') ?? false
|
|
87
|
-
if (groups.length !== (embedsIpv4 ? 7 : 8)) return false
|
|
88
|
-
}
|
|
89
|
-
return true
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Normalize one IP candidate to its canonical bucket key, or `null` when the
|
|
94
|
-
* value is not a usable address.
|
|
95
|
-
*/
|
|
96
|
-
export function normalizeIpForBucketKey(value: string): string | null {
|
|
97
|
-
if (typeof value !== 'string') return null
|
|
98
|
-
let candidate = value.trim()
|
|
99
|
-
if (candidate === '' || candidate.length > IP_BUCKET_KEY_MAX_LENGTH) return null
|
|
100
|
-
|
|
101
|
-
// `[2001:db8::1]` / `[::1]:443` — the bracketed forms a proxy emits so the
|
|
102
|
-
// port is unambiguous.
|
|
103
|
-
if (candidate.startsWith('[')) {
|
|
104
|
-
const close = candidate.indexOf(']')
|
|
105
|
-
if (close === -1) return null
|
|
106
|
-
const trailer = candidate.slice(close + 1)
|
|
107
|
-
if (trailer !== '' && !/^:\d{1,5}$/.test(trailer)) return null
|
|
108
|
-
candidate = candidate.slice(1, close)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// `%eth0` / `%25eth0` scope id — host-local, never part of peer identity.
|
|
112
|
-
const zone = candidate.indexOf('%')
|
|
113
|
-
if (zone !== -1) candidate = candidate.slice(0, zone)
|
|
114
|
-
if (candidate === '') return null
|
|
115
|
-
|
|
116
|
-
// UNBRACKETED `203.0.113.4:8080` — Azure App Service / Front Door and several
|
|
117
|
-
// CDNs write the client hop that way. Rejecting it left the caller with NO
|
|
118
|
-
// candidate at all, collapsing every visitor into the single egress bucket:
|
|
119
|
-
// exactly the silent regression this module exists to prevent. Stripped ONLY
|
|
120
|
-
// when the remainder is a valid IPv4; an unbracketed IPv6 with a bare port
|
|
121
|
-
// (`::1:443`) is genuinely ambiguous — `:443` is equally a final group — so
|
|
122
|
-
// that shape is left to the IPv6 path.
|
|
123
|
-
const portSplit = candidate.lastIndexOf(':')
|
|
124
|
-
if (portSplit > 0 && /^\d{1,5}$/.test(candidate.slice(portSplit + 1))) {
|
|
125
|
-
const head = candidate.slice(0, portSplit)
|
|
126
|
-
if (isIpv4(head)) candidate = head
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (isIpv4(candidate)) return candidate
|
|
130
|
-
|
|
131
|
-
const lowered = candidate.toLowerCase()
|
|
132
|
-
|
|
133
|
-
// IPv4-mapped IPv6 → the bare IPv4 (same peer, one bucket). EVERY spelling
|
|
134
|
-
// is handled: the compressed `::ffff:1.2.3.4` that dual-stack listeners
|
|
135
|
-
// actually emit, the written-out `0:0:0:0:0:ffff:1.2.3.4` that a hop doing
|
|
136
|
-
// its own (non-compressing) formatting can produce, and the ZERO-PADDED
|
|
137
|
-
// `0000:0000:0000:0000:0000:ffff:1.2.3.4` that a fixed-width formatter
|
|
138
|
-
// produces — leading zeros in an IPv6 group are purely cosmetic, so all
|
|
139
|
-
// three name the same peer. Matching only some of them would split one peer
|
|
140
|
-
// across two buckets — the exact failure this module exists to prevent.
|
|
141
|
-
const mapped = /^(?:(?:0{1,4}:){5}|::)ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(lowered)
|
|
142
|
-
if (mapped) return isIpv4(mapped[1]) ? mapped[1] : null
|
|
143
|
-
|
|
144
|
-
if (isIpv6(lowered)) return lowered
|
|
145
|
-
return null
|
|
146
|
-
}
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NATS chunk → normalized `ChatStreamEvent` decoder.
|
|
3
|
-
*
|
|
4
|
-
* THE live NATS reading path — the ONLY chunk parser in the codebase. Every
|
|
5
|
-
* consumer (this lib's `useNatsChatAdapter`, the hub, the product app) feeds
|
|
6
|
-
* raw chunks through `decodeNatsChunk` into `createChatStreamReducer`. The
|
|
7
|
-
* legacy `components/chat/utils/chunk-parser.ts` (`parseChunkToAction`) that
|
|
8
|
-
* this module superseded was DELETED — do not reintroduce a second decoder.
|
|
9
|
-
*
|
|
10
|
-
* Chunks map onto the transport-agnostic event union, with the JetStream
|
|
11
|
-
* `streamSeq` lifted into the `seq` envelope (the reducer's idempotency gate
|
|
12
|
-
* keys off it).
|
|
13
|
-
*
|
|
14
|
-
* Behavior is pinned by `__tests__/nats-decoder-golden.test.ts`.
|
|
15
|
-
*
|
|
16
|
-
* Server-safe: no React, no browser APIs.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { MESSAGE_TYPE } from '../components/chat/types/message.types'
|
|
20
|
-
import type { ApprovalToolCall, ChatStreamEvent } from './events'
|
|
21
|
-
|
|
22
|
-
/** Minimal structural view of a NATS chunk (see `ChunkData` in
|
|
23
|
-
* `src/components/chat/types/network.types.ts`). */
|
|
24
|
-
type NatsChunk = Record<string, any>
|
|
25
|
-
|
|
26
|
-
/** Coerce the wire's `toolCalls[]` into the batch-approval shape, dropping
|
|
27
|
-
* non-object entries and defaulting every field. */
|
|
28
|
-
function normalizeToolCalls(raw: unknown): ApprovalToolCall[] {
|
|
29
|
-
if (!Array.isArray(raw)) return []
|
|
30
|
-
return raw
|
|
31
|
-
.filter((item): item is Record<string, any> => !!item && typeof item === 'object')
|
|
32
|
-
.map((item) => ({
|
|
33
|
-
toolExecutionRequestId: String(item.toolExecutionRequestId ?? ''),
|
|
34
|
-
toolName: String(item.toolName ?? ''),
|
|
35
|
-
toolTitle: typeof item.toolTitle === 'string' ? item.toolTitle : undefined,
|
|
36
|
-
toolExplanation:
|
|
37
|
-
typeof item.toolExplanation === 'string' ? item.toolExplanation : undefined,
|
|
38
|
-
toolType: typeof item.toolType === 'string' ? item.toolType : undefined,
|
|
39
|
-
requiresApproval: item.requiresApproval === true,
|
|
40
|
-
approvalType: typeof item.approvalType === 'string' ? item.approvalType : null,
|
|
41
|
-
toolCallArguments:
|
|
42
|
-
item.toolCallArguments && typeof item.toolCallArguments === 'object'
|
|
43
|
-
? (item.toolCallArguments as Record<string, any>)
|
|
44
|
-
: null,
|
|
45
|
-
}))
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Parse one raw NATS chunk into a normalized event. Returns `null` for
|
|
50
|
-
* unknown/malformed chunks — an unrecognized `type`, a missing required
|
|
51
|
-
* field, or a non-object payload are all tolerated as no-ops rather than
|
|
52
|
-
* throwing, so a backend that adds a chunk type can't break the stream.
|
|
53
|
-
*/
|
|
54
|
-
export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
|
|
55
|
-
if (!chunk || typeof chunk !== 'object') return null
|
|
56
|
-
|
|
57
|
-
const data = chunk as NatsChunk
|
|
58
|
-
const type = String(data.type || '')
|
|
59
|
-
// JetStream stream sequence → the generic `seq` envelope.
|
|
60
|
-
const seq: { seq?: number } =
|
|
61
|
-
typeof data.streamSeq === 'number' ? { seq: data.streamSeq } : {}
|
|
62
|
-
|
|
63
|
-
switch (type) {
|
|
64
|
-
case MESSAGE_TYPE.MESSAGE_START:
|
|
65
|
-
return { type: 'turn-start', ...seq }
|
|
66
|
-
|
|
67
|
-
case MESSAGE_TYPE.MESSAGE_END:
|
|
68
|
-
return { type: 'turn-end', ...seq }
|
|
69
|
-
|
|
70
|
-
case MESSAGE_TYPE.AI_METADATA: {
|
|
71
|
-
const providerName = data.providerName || data.provider
|
|
72
|
-
if (typeof data.modelName === 'string' && typeof providerName === 'string') {
|
|
73
|
-
return {
|
|
74
|
-
type: 'metadata',
|
|
75
|
-
modelLabel: data.modelDisplayName,
|
|
76
|
-
modelName: data.modelName,
|
|
77
|
-
provider: providerName,
|
|
78
|
-
contextWindowMaxTokens:
|
|
79
|
-
typeof data.contextWindow === 'number' ? data.contextWindow : 0,
|
|
80
|
-
...seq,
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
case MESSAGE_TYPE.TEXT:
|
|
87
|
-
if (typeof data.text === 'string') {
|
|
88
|
-
return { type: 'text-delta', text: data.text, ...seq }
|
|
89
|
-
}
|
|
90
|
-
return null
|
|
91
|
-
|
|
92
|
-
case MESSAGE_TYPE.THINKING:
|
|
93
|
-
if (typeof data.text === 'string') {
|
|
94
|
-
return { type: 'thinking-delta', text: data.text, ...seq }
|
|
95
|
-
}
|
|
96
|
-
return null
|
|
97
|
-
|
|
98
|
-
case MESSAGE_TYPE.EXECUTING_TOOL:
|
|
99
|
-
return {
|
|
100
|
-
type: 'tool-execution',
|
|
101
|
-
data: {
|
|
102
|
-
type: 'EXECUTING_TOOL',
|
|
103
|
-
integratedToolType: data.integratedToolType || '',
|
|
104
|
-
toolFunction: data.toolFunction || '',
|
|
105
|
-
toolTitle: typeof data.title === 'string' ? data.title : undefined,
|
|
106
|
-
parameters: data.parameters,
|
|
107
|
-
toolExecutionRequestId:
|
|
108
|
-
typeof data.toolExecutionRequestId === 'string'
|
|
109
|
-
? data.toolExecutionRequestId
|
|
110
|
-
: undefined,
|
|
111
|
-
},
|
|
112
|
-
...seq,
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
case MESSAGE_TYPE.EXECUTED_TOOL:
|
|
116
|
-
return {
|
|
117
|
-
type: 'tool-execution',
|
|
118
|
-
data: {
|
|
119
|
-
type: 'EXECUTED_TOOL',
|
|
120
|
-
integratedToolType: data.integratedToolType || '',
|
|
121
|
-
toolFunction: data.toolFunction || '',
|
|
122
|
-
toolTitle: typeof data.title === 'string' ? data.title : undefined,
|
|
123
|
-
parameters: data.parameters,
|
|
124
|
-
result: data.result,
|
|
125
|
-
success: data.success,
|
|
126
|
-
toolExecutionRequestId:
|
|
127
|
-
typeof data.toolExecutionRequestId === 'string'
|
|
128
|
-
? data.toolExecutionRequestId
|
|
129
|
-
: undefined,
|
|
130
|
-
},
|
|
131
|
-
...seq,
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
case MESSAGE_TYPE.APPROVAL_REQUEST: {
|
|
135
|
-
const requestId = data.approvalRequestId || data.approval_request_id || ''
|
|
136
|
-
const approvalType = data.approvalType || 'USER'
|
|
137
|
-
const toolCalls = normalizeToolCalls(data.toolCalls)
|
|
138
|
-
|
|
139
|
-
if (toolCalls.length > 0) {
|
|
140
|
-
return { type: 'approval-request', requestId, approvalType, toolCalls, ...seq }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
type: 'approval-request',
|
|
145
|
-
requestId,
|
|
146
|
-
approvalType,
|
|
147
|
-
command: data.command || '',
|
|
148
|
-
explanation: data.explanation,
|
|
149
|
-
...seq,
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
case MESSAGE_TYPE.APPROVAL_RESULT: {
|
|
154
|
-
// Realtime chunks carry the resolver's name as `displayName`; the
|
|
155
|
-
// persisted GraphQL message exposes the same value as
|
|
156
|
-
// `resolvedByName`. Accept either so realtime and history-replay
|
|
157
|
-
// render "by {name}" identically.
|
|
158
|
-
const resolvedByName =
|
|
159
|
-
typeof data.resolvedByName === 'string'
|
|
160
|
-
? data.resolvedByName
|
|
161
|
-
: typeof data.displayName === 'string'
|
|
162
|
-
? data.displayName
|
|
163
|
-
: undefined
|
|
164
|
-
return {
|
|
165
|
-
type: 'approval-resolved',
|
|
166
|
-
requestId: data.approvalRequestId || data.approval_request_id || '',
|
|
167
|
-
status: data.approved === true ? 'approved' : 'rejected',
|
|
168
|
-
approvalType: data.approvalType || 'CLIENT',
|
|
169
|
-
resolvedByName,
|
|
170
|
-
...seq,
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
case MESSAGE_TYPE.ERROR:
|
|
175
|
-
return {
|
|
176
|
-
type: 'error',
|
|
177
|
-
title: data.error || 'An error occurred',
|
|
178
|
-
details: data.details,
|
|
179
|
-
...seq,
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
case MESSAGE_TYPE.MESSAGE_REQUEST:
|
|
183
|
-
return {
|
|
184
|
-
type: 'participant',
|
|
185
|
-
kind: 'message-request',
|
|
186
|
-
text: String(data.text || ''),
|
|
187
|
-
ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,
|
|
188
|
-
displayName: typeof data.displayName === 'string' ? data.displayName : undefined,
|
|
189
|
-
userId: typeof data.userId === 'string' ? data.userId : undefined,
|
|
190
|
-
// Wire shape carries no label; fall back to the id (parity with
|
|
191
|
-
// the history path in `process-historical-messages.ts`).
|
|
192
|
-
contextItems: Array.isArray(data.contextItems)
|
|
193
|
-
? (data.contextItems as Array<{ type?: unknown; id?: unknown }>)
|
|
194
|
-
.filter((it) => typeof it?.type === 'string' && typeof it?.id === 'string')
|
|
195
|
-
.map((it) => ({
|
|
196
|
-
type: it.type as string,
|
|
197
|
-
id: it.id as string,
|
|
198
|
-
label: it.id as string,
|
|
199
|
-
}))
|
|
200
|
-
: undefined,
|
|
201
|
-
...seq,
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
case MESSAGE_TYPE.TOKEN_USAGE:
|
|
205
|
-
return {
|
|
206
|
-
type: 'token-usage',
|
|
207
|
-
inputTokensSize: data.inputTokensSize ?? 0,
|
|
208
|
-
outputTokensSize: data.outputTokensSize ?? 0,
|
|
209
|
-
totalTokensSize: data.totalTokensSize ?? 0,
|
|
210
|
-
contextSize: data.contextSize ?? 0,
|
|
211
|
-
...seq,
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
case MESSAGE_TYPE.CONTEXT_COMPACTION_START:
|
|
215
|
-
return { type: 'compaction', phase: 'start', ...seq }
|
|
216
|
-
|
|
217
|
-
case MESSAGE_TYPE.CONTEXT_COMPACTION_END:
|
|
218
|
-
return {
|
|
219
|
-
type: 'compaction',
|
|
220
|
-
phase: 'end',
|
|
221
|
-
summary: typeof data.text === 'string' ? data.text : undefined,
|
|
222
|
-
...seq,
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
case MESSAGE_TYPE.SYSTEM:
|
|
226
|
-
if (typeof data.text === 'string') {
|
|
227
|
-
return { type: 'participant', kind: 'system', text: data.text, ...seq }
|
|
228
|
-
}
|
|
229
|
-
return null
|
|
230
|
-
|
|
231
|
-
case MESSAGE_TYPE.DIRECT_MESSAGE:
|
|
232
|
-
if (typeof data.text === 'string') {
|
|
233
|
-
return {
|
|
234
|
-
type: 'participant',
|
|
235
|
-
kind: 'direct-message',
|
|
236
|
-
text: data.text,
|
|
237
|
-
ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,
|
|
238
|
-
displayName:
|
|
239
|
-
typeof data.displayName === 'string' ? data.displayName : undefined,
|
|
240
|
-
userId: typeof data.userId === 'string' ? data.userId : undefined,
|
|
241
|
-
...seq,
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return null
|
|
245
|
-
|
|
246
|
-
case MESSAGE_TYPE.DIALOG_CLOSED:
|
|
247
|
-
return { type: 'dialog-closed', ...seq }
|
|
248
|
-
|
|
249
|
-
default:
|
|
250
|
-
return null
|
|
251
|
-
}
|
|
252
|
-
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* REGRESSION: `ChatMessageEnhanced` must hand the markdown engine a
|
|
3
|
-
* REFERENTIALLY STABLE `componentOverrides` map across a streamed turn.
|
|
4
|
-
*
|
|
5
|
-
* The engine memoizes each COMPLETED block (`StreamingBlockRenderer`) on its
|
|
6
|
-
* `components` prop identity. `ChatMessageEnhanced` is the sole production
|
|
7
|
-
* caller, and it used to defeat that memo one level up: `cardComponentOverrides`
|
|
8
|
-
* listed `renderingPlan` in its deps, `renderingPlan` is memoized on `segments`,
|
|
9
|
-
* and the stream reducer replaces `segments` on EVERY text delta. So the
|
|
10
|
-
* override map got a new identity per token → the engine's `components` memo
|
|
11
|
-
* re-created → every completed block re-parsed on every token.
|
|
12
|
-
*
|
|
13
|
-
* The engine's own streaming test cannot catch this: it passes a module-scope
|
|
14
|
-
* stable override map. This test drives the real component with the real
|
|
15
|
-
* per-delta `content` churn a reducer produces, and asserts at the SEAM (the
|
|
16
|
-
* props the component hands the renderer) rather than through the engine's
|
|
17
|
-
* internals — the two are separately owned.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import React from 'react'
|
|
21
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
22
|
-
import { render } from '@testing-library/react'
|
|
23
|
-
import type { MessageSegment } from '../types'
|
|
24
|
-
import type { ChatRef } from '../chat-ref.types'
|
|
25
|
-
|
|
26
|
-
/** Every `componentOverrides` / `additionalRemarkPlugins` identity the
|
|
27
|
-
* component handed the markdown renderer, in render order. */
|
|
28
|
-
const seenOverrides: unknown[] = []
|
|
29
|
-
const seenPlugins: unknown[] = []
|
|
30
|
-
|
|
31
|
-
vi.mock('../../ui/markdown/simple-markdown-renderer', () => ({
|
|
32
|
-
SimpleMarkdownRenderer: ({
|
|
33
|
-
content,
|
|
34
|
-
componentOverrides,
|
|
35
|
-
additionalRemarkPlugins,
|
|
36
|
-
}: {
|
|
37
|
-
content: string
|
|
38
|
-
componentOverrides?: unknown
|
|
39
|
-
additionalRemarkPlugins?: unknown
|
|
40
|
-
}) => {
|
|
41
|
-
seenOverrides.push(componentOverrides)
|
|
42
|
-
seenPlugins.push(additionalRemarkPlugins)
|
|
43
|
-
return <div data-testid="md">{content}</div>
|
|
44
|
-
},
|
|
45
|
-
}))
|
|
46
|
-
|
|
47
|
-
import { ChatMessageEnhanced } from '../chat-message-enhanced'
|
|
48
|
-
|
|
49
|
-
function NavLinkAnchor({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
50
|
-
return <a href={href}>{children}</a>
|
|
51
|
-
}
|
|
52
|
-
const CHAT_REFS: Record<string, ChatRef> = {}
|
|
53
|
-
const renderMention = () => null
|
|
54
|
-
|
|
55
|
-
/** Fresh array per call — the reducer replaces `segments` on every delta. */
|
|
56
|
-
const segmentsFor = (tail: string): MessageSegment[] => [
|
|
57
|
-
{ type: 'text', text: `First paragraph, already complete.\n\n${tail}` },
|
|
58
|
-
]
|
|
59
|
-
|
|
60
|
-
describe('ChatMessageEnhanced — override identity across a streamed turn', () => {
|
|
61
|
-
it('keeps componentOverrides + remark plugins referentially stable per token', () => {
|
|
62
|
-
seenOverrides.length = 0
|
|
63
|
-
seenPlugins.length = 0
|
|
64
|
-
|
|
65
|
-
const props = {
|
|
66
|
-
role: 'assistant' as const,
|
|
67
|
-
isTyping: true,
|
|
68
|
-
chatRefs: CHAT_REFS,
|
|
69
|
-
renderMention,
|
|
70
|
-
NavLinkAnchor,
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const view = render(<ChatMessageEnhanced {...props} content={segmentsFor('Tail')} />)
|
|
74
|
-
expect(seenOverrides.length).toBeGreaterThan(0)
|
|
75
|
-
const firstOverrides = seenOverrides[0]
|
|
76
|
-
const firstPlugins = seenPlugins[0]
|
|
77
|
-
expect(firstOverrides).toBeTruthy()
|
|
78
|
-
|
|
79
|
-
let tail = 'Tail'
|
|
80
|
-
for (let i = 0; i < 10; i += 1) {
|
|
81
|
-
tail += ` token${i}`
|
|
82
|
-
view.rerender(<ChatMessageEnhanced {...props} content={segmentsFor(tail)} />)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ONE identity for the whole turn. Before the fix this produced a fresh
|
|
86
|
-
// object on each of the 11 renders.
|
|
87
|
-
expect(new Set(seenOverrides).size).toBe(1)
|
|
88
|
-
expect(seenOverrides.every((o) => o === firstOverrides)).toBe(true)
|
|
89
|
-
expect(new Set(seenPlugins).size).toBe(1)
|
|
90
|
-
expect(seenPlugins.every((p) => p === firstPlugins)).toBe(true)
|
|
91
|
-
|
|
92
|
-
// Sanity: the tail really did stream through.
|
|
93
|
-
expect(view.container.textContent).toContain('token9')
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('still resolves card markers through the CURRENT plan (ref-read, not closure)', () => {
|
|
97
|
-
seenOverrides.length = 0
|
|
98
|
-
const refs: Record<string, ChatRef> = {
|
|
99
|
-
'blog:abc': { type: 'blog', id: 'abc', title: 'Resolved Title', url: null },
|
|
100
|
-
}
|
|
101
|
-
render(
|
|
102
|
-
<ChatMessageEnhanced
|
|
103
|
-
role="assistant"
|
|
104
|
-
content={[{ type: 'text', text: 'See [card://blog:abc] for more.' }]}
|
|
105
|
-
chatRefs={refs}
|
|
106
|
-
NavLinkAnchor={NavLinkAnchor}
|
|
107
|
-
/>,
|
|
108
|
-
)
|
|
109
|
-
const overrides = seenOverrides[0] as { a: React.FC<{ href: string; children?: React.ReactNode }> }
|
|
110
|
-
const out = render(<>{overrides.a({ href: 'card://blog:abc', children: 'x' })}</>)
|
|
111
|
-
expect(out.container.textContent).toBe('Resolved Title')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* REGRESSION (round 3): a ref that resolves AFTER the message finished
|
|
116
|
-
* streaming must still reach the pill. The override reads `chatRefs`
|
|
117
|
-
* through a ref, so its own closure is fine — but the markdown engine
|
|
118
|
-
* memoizes completed blocks on the override map's IDENTITY. With
|
|
119
|
-
* `chatRefs` excluded from the override memo's deps entirely, the map kept
|
|
120
|
-
* its old identity, the engine bailed, and the marker rendered its raw-id
|
|
121
|
-
* fallback permanently.
|
|
122
|
-
*/
|
|
123
|
-
it('gives componentOverrides a NEW identity when the ref set grows (completed message)', () => {
|
|
124
|
-
seenOverrides.length = 0
|
|
125
|
-
const content: MessageSegment[] = [{ type: 'text', text: 'See [card://blog:late] for more.' }]
|
|
126
|
-
const emptyRefs: Record<string, ChatRef> = {}
|
|
127
|
-
const props = {
|
|
128
|
-
role: 'assistant' as const,
|
|
129
|
-
isTyping: false,
|
|
130
|
-
content,
|
|
131
|
-
NavLinkAnchor,
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const view = render(<ChatMessageEnhanced {...props} chatRefs={emptyRefs} />)
|
|
135
|
-
const before = seenOverrides[seenOverrides.length - 1] as {
|
|
136
|
-
a: React.FC<{ href: string; children?: React.ReactNode }>
|
|
137
|
-
}
|
|
138
|
-
// Unresolved: the raw card id is surfaced so the breakage is visible.
|
|
139
|
-
expect(render(<>{before.a({ href: 'card://blog:late', children: 'x' })}</>).container.textContent)
|
|
140
|
-
.toBe('late')
|
|
141
|
-
|
|
142
|
-
// Same content, same everything — only the refs map gains the entry.
|
|
143
|
-
const resolvedRefs: Record<string, ChatRef> = {
|
|
144
|
-
'blog:late': { type: 'blog', id: 'late', title: 'Late Title', url: null },
|
|
145
|
-
}
|
|
146
|
-
view.rerender(<ChatMessageEnhanced {...props} chatRefs={resolvedRefs} />)
|
|
147
|
-
|
|
148
|
-
const after = seenOverrides[seenOverrides.length - 1] as {
|
|
149
|
-
a: React.FC<{ href: string; children?: React.ReactNode }>
|
|
150
|
-
}
|
|
151
|
-
expect(after).not.toBe(before)
|
|
152
|
-
expect(render(<>{after.a({ href: 'card://blog:late', children: 'x' })}</>).container.textContent)
|
|
153
|
-
.toBe('Late Title')
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* REGRESSION (round 5): a later refs frame REPLACES the map for a send index
|
|
158
|
-
* with the SAME key set but richer values — the `title` going from the raw
|
|
159
|
-
* cardId to the resolved document title is the exact production case. A
|
|
160
|
-
* key-set-only token produced an identical string, so the override map kept
|
|
161
|
-
* its identity, the engine's completed-block memo bailed, and the pill stayed
|
|
162
|
-
* on its fallback forever. The token fingerprints VALUES now.
|
|
163
|
-
*/
|
|
164
|
-
it('gives componentOverrides a NEW identity when a ref VALUE is enriched (same key set)', () => {
|
|
165
|
-
seenOverrides.length = 0
|
|
166
|
-
const content: MessageSegment[] = [{ type: 'text', text: 'See [card://blog:abc].' }]
|
|
167
|
-
const props = { role: 'assistant' as const, isTyping: false, content, NavLinkAnchor }
|
|
168
|
-
|
|
169
|
-
// First frame: the ref exists but its title is still the raw card id.
|
|
170
|
-
const stub: Record<string, ChatRef> = {
|
|
171
|
-
'blog:abc': { type: 'blog', id: 'abc', title: 'abc', url: null },
|
|
172
|
-
}
|
|
173
|
-
const view = render(<ChatMessageEnhanced {...props} chatRefs={stub} />)
|
|
174
|
-
const before = seenOverrides[seenOverrides.length - 1] as {
|
|
175
|
-
a: React.FC<{ href: string; children?: React.ReactNode }>
|
|
176
|
-
}
|
|
177
|
-
expect(render(<>{before.a({ href: 'card://blog:abc', children: 'x' })}</>).container.textContent)
|
|
178
|
-
.toBe('abc')
|
|
179
|
-
|
|
180
|
-
// Second frame: SAME key set, enriched value.
|
|
181
|
-
const enriched: Record<string, ChatRef> = {
|
|
182
|
-
'blog:abc': { type: 'blog', id: 'abc', title: 'Resolved Document Title', url: null },
|
|
183
|
-
}
|
|
184
|
-
view.rerender(<ChatMessageEnhanced {...props} chatRefs={enriched} />)
|
|
185
|
-
|
|
186
|
-
const after = seenOverrides[seenOverrides.length - 1] as {
|
|
187
|
-
a: React.FC<{ href: string; children?: React.ReactNode }>
|
|
188
|
-
}
|
|
189
|
-
expect(after).not.toBe(before)
|
|
190
|
-
expect(render(<>{after.a({ href: 'card://blog:abc', children: 'x' })}</>).container.textContent)
|
|
191
|
-
.toBe('Resolved Document Title')
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
/** …but a same-KEY refs object (new identity, same entries) must NOT churn
|
|
195
|
-
* the override map — that is the streaming-stability guarantee above. */
|
|
196
|
-
it('keeps the override identity when only the refs object identity changes', () => {
|
|
197
|
-
seenOverrides.length = 0
|
|
198
|
-
const content: MessageSegment[] = [{ type: 'text', text: 'See [card://blog:abc].' }]
|
|
199
|
-
const ref: ChatRef = { type: 'blog', id: 'abc', title: 'T', url: null }
|
|
200
|
-
const props = { role: 'assistant' as const, content, NavLinkAnchor }
|
|
201
|
-
|
|
202
|
-
const view = render(<ChatMessageEnhanced {...props} chatRefs={{ 'blog:abc': ref }} />)
|
|
203
|
-
view.rerender(<ChatMessageEnhanced {...props} chatRefs={{ 'blog:abc': ref }} />)
|
|
204
|
-
|
|
205
|
-
expect(new Set(seenOverrides).size).toBe(1)
|
|
206
|
-
})
|
|
207
|
-
})
|