@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,209 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pending-turn + hidden-row contracts.
|
|
3
|
-
*
|
|
4
|
-
* Two reader-visible defects this pins against:
|
|
5
|
-
*
|
|
6
|
-
* 1. Approving a tool call minted an author-label row with NO body
|
|
7
|
-
* ("Michael Assraf:" then nothing). The approval decision is
|
|
8
|
-
* out-of-band metadata, so the reducer must not materialize a user
|
|
9
|
-
* row for it at all — fixing it at render would leave dead state.
|
|
10
|
-
* 2. While the model was thinking, the transcript showed a second,
|
|
11
|
-
* empty "Mingo:" row ABOVE the footer progress indicator — two
|
|
12
|
-
* owners for one pending turn, and an `aria-live` log announcing a
|
|
13
|
-
* bare "Mingo". Exactly one affordance must be present.
|
|
14
|
-
*
|
|
15
|
-
* Plus the two idioms that must SURVIVE both fixes: a hidden row that
|
|
16
|
-
* carries real text (auto-continuation directive) stays in state while
|
|
17
|
-
* never rendering, and the SYSTEM name-only row still renders.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { render } from '@testing-library/react'
|
|
21
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
22
|
-
import { createChatStreamReducer } from '../stream/chat-stream-reducer'
|
|
23
|
-
import {
|
|
24
|
-
AUTO_CONTINUATION_DIRECTIVE_PREFIX,
|
|
25
|
-
buildAutoContinuationDirective,
|
|
26
|
-
} from '../utils/auto-continuation-directive'
|
|
27
|
-
import type { Message } from '../types/message.types'
|
|
28
|
-
|
|
29
|
-
// ─── Render harness (mirrors chat-message-list.test.tsx) ────────────────────
|
|
30
|
-
|
|
31
|
-
const scrollToBottom = vi.fn()
|
|
32
|
-
const stopScroll = vi.fn()
|
|
33
|
-
|
|
34
|
-
function makeRef() {
|
|
35
|
-
const fn = ((el: HTMLElement | null) => {
|
|
36
|
-
;(fn as { current: HTMLElement | null }).current = el
|
|
37
|
-
}) as ((el: HTMLElement | null) => void) & { current: HTMLElement | null }
|
|
38
|
-
fn.current = null
|
|
39
|
-
return fn
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
vi.mock('use-stick-to-bottom', () => ({
|
|
43
|
-
useStickToBottom: () => ({
|
|
44
|
-
scrollRef: makeRef(),
|
|
45
|
-
contentRef: makeRef(),
|
|
46
|
-
scrollToBottom,
|
|
47
|
-
stopScroll,
|
|
48
|
-
}),
|
|
49
|
-
}))
|
|
50
|
-
|
|
51
|
-
// Row stub that renders the author label the way the real component does
|
|
52
|
-
// (`Name:`), so "labeled row with no body" is directly assertable.
|
|
53
|
-
vi.mock('../chat-message-enhanced', () => ({
|
|
54
|
-
ChatMessageEnhanced: ({ name, content }: { name?: string; content: unknown }) => (
|
|
55
|
-
<div data-testid="row">
|
|
56
|
-
<span data-testid="row-name">{name ?? ''}</span>
|
|
57
|
-
<span data-testid="row-body">
|
|
58
|
-
{typeof content === 'string'
|
|
59
|
-
? content
|
|
60
|
-
: Array.isArray(content)
|
|
61
|
-
? content
|
|
62
|
-
.map((s) => (s && (s as { type?: string }).type === 'text' ? (s as { text: string }).text : '[seg]'))
|
|
63
|
-
.join('')
|
|
64
|
-
: ''}
|
|
65
|
-
</span>
|
|
66
|
-
</div>
|
|
67
|
-
),
|
|
68
|
-
}))
|
|
69
|
-
|
|
70
|
-
import { ChatMessageList } from '../chat-message-list'
|
|
71
|
-
|
|
72
|
-
class ObserverStub {
|
|
73
|
-
observe() {}
|
|
74
|
-
unobserve() {}
|
|
75
|
-
disconnect() {}
|
|
76
|
-
}
|
|
77
|
-
vi.stubGlobal('IntersectionObserver', ObserverStub)
|
|
78
|
-
vi.stubGlobal('ResizeObserver', ObserverStub)
|
|
79
|
-
|
|
80
|
-
const names = (c: HTMLElement) =>
|
|
81
|
-
Array.from(c.querySelectorAll('[data-testid="row-name"]')).map((n) => n.textContent)
|
|
82
|
-
const bodies = (c: HTMLElement) =>
|
|
83
|
-
Array.from(c.querySelectorAll('[data-testid="row-body"]')).map((n) => n.textContent)
|
|
84
|
-
|
|
85
|
-
// ─── State-level contracts ─────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
describe('approval sends mint no visible user row', () => {
|
|
88
|
-
it('beginSseSend with empty text pushes only the assistant placeholder', () => {
|
|
89
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
90
|
-
// What `cardApprove` triggers: ('', { hidden: true, approvalAction }).
|
|
91
|
-
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
|
|
92
|
-
|
|
93
|
-
expect(r.state.messages).toHaveLength(1)
|
|
94
|
-
expect(r.state.messages[0].role).toBe('assistant')
|
|
95
|
-
expect(r.state.messages.some((m) => m.role === 'user')).toBe(false)
|
|
96
|
-
expect(r.state.streamingPhase).toBe('thinking')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('pushOptimisticSend with empty text likewise mints no user row', () => {
|
|
100
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
101
|
-
r.pushOptimisticSend('', true)
|
|
102
|
-
|
|
103
|
-
expect(r.state.messages).toHaveLength(1)
|
|
104
|
-
expect(r.state.messages[0].role).toBe('assistant')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('a normal send still mints the user row', () => {
|
|
108
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
109
|
-
r.beginSseSend({ text: 'hello', assistantName: 'Mingo AI' })
|
|
110
|
-
|
|
111
|
-
expect(r.state.messages).toHaveLength(2)
|
|
112
|
-
expect(r.state.messages[0]).toMatchObject({ role: 'user', content: 'hello' })
|
|
113
|
-
expect(r.state.messages[0].hidden).toBeUndefined()
|
|
114
|
-
})
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
describe('hidden rows that carry text', () => {
|
|
118
|
-
it('an auto-continuation directive IS state (the model sees it) but is flagged hidden', () => {
|
|
119
|
-
const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
|
|
120
|
-
expect(directive.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)).toBe(true)
|
|
121
|
-
|
|
122
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
123
|
-
r.beginSseSend({ text: directive, hidden: true, assistantName: 'Mingo AI' })
|
|
124
|
-
|
|
125
|
-
const userRow = r.state.messages.find((m) => m.role === 'user')
|
|
126
|
-
expect(userRow?.content).toBe(directive)
|
|
127
|
-
expect(userRow?.hidden).toBe(true)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('never renders — the raw directive text must not reach the transcript', () => {
|
|
131
|
-
const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
|
|
132
|
-
const messages: Message[] = [
|
|
133
|
-
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'my laptop is dead' },
|
|
134
|
-
{ id: 'h1', role: 'user', name: 'Michael Assraf', content: directive, hidden: true },
|
|
135
|
-
{ id: 'a1', role: 'assistant', name: 'Mingo', content: 'Opened a ticket.' },
|
|
136
|
-
]
|
|
137
|
-
const { container } = render(<ChatMessageList dialogId="d" messages={messages} />)
|
|
138
|
-
|
|
139
|
-
expect(container.textContent).not.toContain(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
|
|
140
|
-
expect(bodies(container)).toEqual(['my laptop is dead', 'Opened a ticket.'])
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// ─── Render-level contracts ────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
describe('a thinking turn has exactly ONE pending affordance', () => {
|
|
147
|
-
const pending: Message[] = [
|
|
148
|
-
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'what is broken?' },
|
|
149
|
-
{ id: 'a1', role: 'assistant', name: 'Mingo', content: '', segments: [] },
|
|
150
|
-
]
|
|
151
|
-
|
|
152
|
-
it('does not render the empty labeled assistant row while typing', () => {
|
|
153
|
-
const { container } = render(
|
|
154
|
-
<ChatMessageList dialogId="d" messages={pending} isTyping />,
|
|
155
|
-
)
|
|
156
|
-
// The bare "Mingo" label must be gone from the transcript entirely —
|
|
157
|
-
// removed from the DOM, not visually hidden (the list is an aria-live
|
|
158
|
-
// log; a zero-height node would still be announced).
|
|
159
|
-
expect(names(container)).toEqual(['Michael Assraf'])
|
|
160
|
-
expect(bodies(container)).toEqual(['what is broken?'])
|
|
161
|
-
// …and the single remaining owner is the polite status indicator.
|
|
162
|
-
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
it('renders the assistant row as soon as it carries text', () => {
|
|
166
|
-
const withText: Message[] = [
|
|
167
|
-
pending[0],
|
|
168
|
-
{ id: 'a1', role: 'assistant', name: 'Mingo', content: [{ type: 'text', text: 'Chec' }] },
|
|
169
|
-
]
|
|
170
|
-
const { container } = render(
|
|
171
|
-
<ChatMessageList dialogId="d" messages={withText} isTyping />,
|
|
172
|
-
)
|
|
173
|
-
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
it('renders the assistant row when it carries a NON-text segment (tool call)', () => {
|
|
177
|
-
const withTool: Message[] = [
|
|
178
|
-
pending[0],
|
|
179
|
-
{
|
|
180
|
-
id: 'a1',
|
|
181
|
-
role: 'assistant',
|
|
182
|
-
name: 'Mingo',
|
|
183
|
-
content: [
|
|
184
|
-
{ type: 'tool_execution', toolExecutionRequestId: 'e1', status: 'executing' },
|
|
185
|
-
] as Message['content'],
|
|
186
|
-
},
|
|
187
|
-
]
|
|
188
|
-
const { container } = render(
|
|
189
|
-
<ChatMessageList dialogId="d" messages={withTool} isTyping />,
|
|
190
|
-
)
|
|
191
|
-
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
it('renders a completed empty-ish assistant row when NOT typing', () => {
|
|
195
|
-
const { container } = render(<ChatMessageList dialogId="d" messages={pending} />)
|
|
196
|
-
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
|
|
197
|
-
})
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
describe('the SYSTEM name-only row is a legitimate label-without-body', () => {
|
|
201
|
-
it('still renders', () => {
|
|
202
|
-
const messages: Message[] = [
|
|
203
|
-
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'hi' },
|
|
204
|
-
{ id: 's1', role: 'user', name: 'Technician joined', content: '', authorType: 'system' },
|
|
205
|
-
]
|
|
206
|
-
const { container } = render(<ChatMessageList dialogId="d" messages={messages} isTyping />)
|
|
207
|
-
expect(names(container)).toContain('Technician joined')
|
|
208
|
-
})
|
|
209
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
-
|
|
3
|
-
exports[`server-history hydration (materializes through the reducer) > rebuilds messages + sendIdx-keyed refs + send counter from the transcript 1`] = `
|
|
4
|
-
[
|
|
5
|
-
{
|
|
6
|
-
"content": "open a ticket for the broken printer",
|
|
7
|
-
"id": "hydrated-1",
|
|
8
|
-
"role": "user",
|
|
9
|
-
},
|
|
10
|
-
{
|
|
11
|
-
"chatRefs": {
|
|
12
|
-
"ticket:77": {
|
|
13
|
-
"id": "77",
|
|
14
|
-
"metadata": {
|
|
15
|
-
"status": "OPEN",
|
|
16
|
-
},
|
|
17
|
-
"title": "Broken printer",
|
|
18
|
-
"type": "ticket",
|
|
19
|
-
"url": null,
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
"content": "✅ Approved — ticket created: [card://ticket:77]",
|
|
23
|
-
"id": "hydrated-2",
|
|
24
|
-
"role": "assistant",
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
"content": "what is the pricing?",
|
|
28
|
-
"id": "hydrated-3",
|
|
29
|
-
"role": "user",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
"content": "Pricing starts at $99/month per site.",
|
|
33
|
-
"id": "hydrated-4",
|
|
34
|
-
"role": "assistant",
|
|
35
|
-
},
|
|
36
|
-
]
|
|
37
|
-
`;
|
|
Binary file
|
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GOLDEN CONTRACT TESTS — server-issued conversation ids + server-side
|
|
3
|
-
* history hydration in the SSE adapter.
|
|
4
|
-
*
|
|
5
|
-
* Successor to the retired `persisted-state-v1-golden.test.ts`: the
|
|
6
|
-
* PersistedChatState v1 full-history localStorage blob is DELETED by design
|
|
7
|
-
* (memory `project_chat_server_history_ssot`). The contract pinned here is
|
|
8
|
-
* its replacement:
|
|
9
|
-
*
|
|
10
|
-
* - conversation ids are SERVER-minted, captured from the leading
|
|
11
|
-
* metadata frame, and localStorage stores ONLY the echoed id
|
|
12
|
-
* (`mingo-chat-<source>.conversation`);
|
|
13
|
-
* - the wire carries ONLY the newest user message (+ the echoed
|
|
14
|
-
* conversation id) — never a replay of client history;
|
|
15
|
-
* - mount-time history hydrates from `GET <chatStreamUrl>/history` and
|
|
16
|
-
* materializes THROUGH THE REDUCER (messages, sendIdx-keyed refs, send
|
|
17
|
-
* counter — same fan-out lookup as live turns);
|
|
18
|
-
* - stale keys — other proxy-auth identities AND the retired v1
|
|
19
|
-
* full-history blobs — are swept on mount; a leftover v1 blob neither
|
|
20
|
-
* crashes the adapter nor resurrects its messages.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
24
|
-
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
25
|
-
import { createElement, type ReactNode } from 'react'
|
|
26
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
27
|
-
import { useSseChatAdapter } from '../use-sse-chat-adapter'
|
|
28
|
-
import { ChatRuntimeContext, type ChatRuntime } from '../../../../contexts/chat-runtime-context'
|
|
29
|
-
import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../../utils/auto-continuation-directive'
|
|
30
|
-
import {
|
|
31
|
-
createChatConversationStorage,
|
|
32
|
-
pruneStaleChatConversationStorage,
|
|
33
|
-
} from '../../utils/chat-conversation-storage'
|
|
34
|
-
|
|
35
|
-
const SOURCE = 'goldensrc'
|
|
36
|
-
/** `createLocalStorageAdapter` key shape: `<namespace>.<key>`. */
|
|
37
|
-
const CONVERSATION_KEY = `mingo-chat-${SOURCE}.conversation`
|
|
38
|
-
/** Retired v1 full-history key (pre-server-SSOT design). */
|
|
39
|
-
const V1_KEY = `mingo-chat-${SOURCE}-v1`
|
|
40
|
-
|
|
41
|
-
const runtime: ChatRuntime = {
|
|
42
|
-
endpoints: {
|
|
43
|
-
chatStreamUrl: '/api/docs/chat',
|
|
44
|
-
approvalToolUrl: '/api/chat/agent/confirm-tool',
|
|
45
|
-
commandsUrl: '/api/docs/commands',
|
|
46
|
-
buildListUrl: () => null,
|
|
47
|
-
attachmentUploadUrl: '/api/storage/generate-upload-url',
|
|
48
|
-
attachmentViewUrlPrefix: '/api/storage/view/chat-attachments/',
|
|
49
|
-
identityUrl: '/api/chat/identity',
|
|
50
|
-
},
|
|
51
|
-
navigation: { mode: 'host' },
|
|
52
|
-
source: SOURCE,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function wrapper({ children }: { children: ReactNode }) {
|
|
56
|
-
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
57
|
-
return createElement(
|
|
58
|
-
QueryClientProvider,
|
|
59
|
-
{ client: queryClient },
|
|
60
|
-
createElement(ChatRuntimeContext.Provider, { value: runtime }, children),
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const enc = new TextEncoder()
|
|
65
|
-
|
|
66
|
-
function streamOf(chunks: Array<string | Uint8Array>): ReadableStream<Uint8Array> {
|
|
67
|
-
return new ReadableStream<Uint8Array>({
|
|
68
|
-
start(controller) {
|
|
69
|
-
for (const c of chunks) {
|
|
70
|
-
controller.enqueue(typeof c === 'string' ? enc.encode(c) : c)
|
|
71
|
-
}
|
|
72
|
-
controller.close()
|
|
73
|
-
},
|
|
74
|
-
})
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* URL-routing fetch mock:
|
|
79
|
-
* GET …/history → the provided history payload (404 when omitted)
|
|
80
|
-
* GET …/commands → empty registry (adapter degrades silently)
|
|
81
|
-
* POST chat stream → one streamed wire response per call, in order
|
|
82
|
-
* (the LAST entry repeats for any further sends).
|
|
83
|
-
*/
|
|
84
|
-
function mockRoutedFetch(options: {
|
|
85
|
-
history?: unknown
|
|
86
|
-
streams: Array<Array<string | Uint8Array>>
|
|
87
|
-
}) {
|
|
88
|
-
let streamCall = 0
|
|
89
|
-
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
|
|
90
|
-
const u = String(url)
|
|
91
|
-
if (u.includes('/history')) {
|
|
92
|
-
if (options.history === undefined) {
|
|
93
|
-
return { ok: false, status: 404 } as unknown as Response
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
ok: true,
|
|
97
|
-
status: 200,
|
|
98
|
-
json: async () => options.history,
|
|
99
|
-
} as unknown as Response
|
|
100
|
-
}
|
|
101
|
-
if (u.includes('/commands')) {
|
|
102
|
-
return {
|
|
103
|
-
ok: true,
|
|
104
|
-
status: 200,
|
|
105
|
-
json: async () => ({ commands: [] }),
|
|
106
|
-
} as unknown as Response
|
|
107
|
-
}
|
|
108
|
-
const chunks = options.streams[Math.min(streamCall, options.streams.length - 1)]
|
|
109
|
-
streamCall += 1
|
|
110
|
-
return { ok: true, status: 200, body: streamOf(chunks) } as unknown as Response
|
|
111
|
-
})
|
|
112
|
-
vi.stubGlobal('fetch', fetchMock)
|
|
113
|
-
return fetchMock
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** POST bodies the mock received for the chat-stream endpoint only. */
|
|
117
|
-
function chatBodies(fetchMock: ReturnType<typeof vi.fn>): Array<Record<string, unknown>> {
|
|
118
|
-
return fetchMock.mock.calls
|
|
119
|
-
.filter(([url]) => String(url).includes('/api/docs/chat') && !String(url).includes('/history'))
|
|
120
|
-
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const USAGE_END_TRAILER =
|
|
124
|
-
'{"kind":"usage","stage":"end","input_tokens":10,"output_tokens":5,"hit_rate_pct":50}'
|
|
125
|
-
|
|
126
|
-
/** Wire fixture: metadata frame echoing a server-minted conversation id. */
|
|
127
|
-
function wireTurn(conversationId: string, answer: string): Array<string> {
|
|
128
|
-
return [
|
|
129
|
-
`{"modelLabel":"Claude Sonnet","provider":"anthropic","conversationId":"${conversationId}"}\0`,
|
|
130
|
-
'\x1E',
|
|
131
|
-
answer,
|
|
132
|
-
'\x1F' + USAGE_END_TRAILER,
|
|
133
|
-
]
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Server transcript fixture — 2 turns; turn #0's assistant row carries a
|
|
137
|
-
* card ref that must land at sendIdx 0 (same fan-out scheme as live). */
|
|
138
|
-
const HISTORY_PAYLOAD = {
|
|
139
|
-
data: {
|
|
140
|
-
messages: [
|
|
141
|
-
{
|
|
142
|
-
seq: 1,
|
|
143
|
-
role: 'user',
|
|
144
|
-
content: 'open a ticket for the broken printer',
|
|
145
|
-
created_at: '2026-07-19T09:00:00.000Z',
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
seq: 2,
|
|
149
|
-
role: 'assistant',
|
|
150
|
-
content: '✅ Approved — ticket created: [card://ticket:77]',
|
|
151
|
-
created_at: '2026-07-19T09:00:12.000Z',
|
|
152
|
-
chat_refs: {
|
|
153
|
-
'ticket:77': {
|
|
154
|
-
type: 'ticket',
|
|
155
|
-
id: '77',
|
|
156
|
-
title: 'Broken printer',
|
|
157
|
-
url: null,
|
|
158
|
-
metadata: { status: 'OPEN' },
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
seq: 3,
|
|
164
|
-
role: 'user',
|
|
165
|
-
content: 'what is the pricing?',
|
|
166
|
-
created_at: '2026-07-19T09:01:40.000Z',
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
seq: 4,
|
|
170
|
-
role: 'assistant',
|
|
171
|
-
content: 'Pricing starts at $99/month per site.',
|
|
172
|
-
created_at: '2026-07-19T09:01:45.000Z',
|
|
173
|
-
},
|
|
174
|
-
],
|
|
175
|
-
},
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** The retired v1 blob shape (abbreviated) — must be swept, never read. */
|
|
179
|
-
const V1_BLOB = {
|
|
180
|
-
messages: [
|
|
181
|
-
{ id: 'user-1', role: 'user', name: 'You', content: 'old v1 question' },
|
|
182
|
-
{
|
|
183
|
-
id: 'assistant-1',
|
|
184
|
-
role: 'assistant',
|
|
185
|
-
name: 'Mingo AI',
|
|
186
|
-
content: [{ type: 'text', text: 'old v1 answer' }],
|
|
187
|
-
},
|
|
188
|
-
],
|
|
189
|
-
sources: [],
|
|
190
|
-
refs: [],
|
|
191
|
-
sendCount: 1,
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const tick = () => new Promise<void>((r) => setTimeout(r, 0))
|
|
195
|
-
|
|
196
|
-
beforeEach(() => {
|
|
197
|
-
window.localStorage.clear()
|
|
198
|
-
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
afterEach(() => {
|
|
202
|
-
vi.unstubAllGlobals()
|
|
203
|
-
vi.restoreAllMocks()
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
describe('conversation-id storage — id-only round-trip', () => {
|
|
207
|
-
it('save → load → clear round-trips exactly the conversation id', () => {
|
|
208
|
-
const storage = createChatConversationStorage(SOURCE)
|
|
209
|
-
expect(storage.load()).toBeNull()
|
|
210
|
-
storage.save({ conversationId: 'conv-rt-1' })
|
|
211
|
-
// On-disk shape is the id and NOTHING else — no messages, sources,
|
|
212
|
-
// refs, or send counts ever reach localStorage.
|
|
213
|
-
expect(JSON.parse(window.localStorage.getItem(CONVERSATION_KEY)!)).toEqual({
|
|
214
|
-
conversationId: 'conv-rt-1',
|
|
215
|
-
})
|
|
216
|
-
expect(storage.load()).toEqual({ conversationId: 'conv-rt-1' })
|
|
217
|
-
storage.clear()
|
|
218
|
-
expect(storage.load()).toBeNull()
|
|
219
|
-
expect(window.localStorage.getItem(CONVERSATION_KEY)).toBeNull()
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('rejects malformed blobs via the validate gate', () => {
|
|
223
|
-
window.localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ conversationId: '' }))
|
|
224
|
-
expect(createChatConversationStorage(SOURCE).load()).toBeNull()
|
|
225
|
-
window.localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ nope: true }))
|
|
226
|
-
expect(createChatConversationStorage(SOURCE).load()).toBeNull()
|
|
227
|
-
})
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
describe('stale-key pruning', () => {
|
|
231
|
-
it('sweeps other-identity keys and retired v1/full-history shapes, keeps the current key and sibling sources', () => {
|
|
232
|
-
window.localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ conversationId: 'conv-keep' }))
|
|
233
|
-
window.localStorage.setItem(`mingo-chat-${SOURCE}`, 'retired raw shape')
|
|
234
|
-
window.localStorage.setItem(V1_KEY, JSON.stringify(V1_BLOB))
|
|
235
|
-
window.localStorage.setItem(
|
|
236
|
-
`mingo-chat-${SOURCE}-u-someone%40example.com.conversation`,
|
|
237
|
-
JSON.stringify({ conversationId: 'conv-other-identity' }),
|
|
238
|
-
)
|
|
239
|
-
// Hyphen-extended SIBLING source — NOT this source's namespace.
|
|
240
|
-
window.localStorage.setItem(
|
|
241
|
-
`mingo-chat-${SOURCE}-teaser.conversation`,
|
|
242
|
-
JSON.stringify({ conversationId: 'conv-sibling' }),
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
pruneStaleChatConversationStorage(SOURCE)
|
|
246
|
-
|
|
247
|
-
expect(window.localStorage.getItem(CONVERSATION_KEY)).not.toBeNull()
|
|
248
|
-
expect(window.localStorage.getItem(`mingo-chat-${SOURCE}`)).toBeNull()
|
|
249
|
-
expect(window.localStorage.getItem(V1_KEY)).toBeNull()
|
|
250
|
-
expect(
|
|
251
|
-
window.localStorage.getItem(`mingo-chat-${SOURCE}-u-someone%40example.com.conversation`),
|
|
252
|
-
).toBeNull()
|
|
253
|
-
expect(window.localStorage.getItem(`mingo-chat-${SOURCE}-teaser.conversation`)).not.toBeNull()
|
|
254
|
-
})
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
describe('server-minted conversation id (full hook path)', () => {
|
|
258
|
-
it('captures the echoed id, persists id-only, and sends newest-message-only bodies', async () => {
|
|
259
|
-
const fetchMock = mockRoutedFetch({
|
|
260
|
-
streams: [wireTurn('conv-golden-1', 'First answer.'), wireTurn('conv-golden-1', 'Second answer.')],
|
|
261
|
-
})
|
|
262
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
263
|
-
wrapper,
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
await act(async () => {
|
|
267
|
-
await result.current.sendMessage('first question')
|
|
268
|
-
})
|
|
269
|
-
// The echoed id — and ONLY the id — is persisted.
|
|
270
|
-
expect(JSON.parse(window.localStorage.getItem(CONVERSATION_KEY)!)).toEqual({
|
|
271
|
-
conversationId: 'conv-golden-1',
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
await act(async () => {
|
|
275
|
-
await result.current.sendMessage('second question')
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
const bodies = chatBodies(fetchMock)
|
|
279
|
-
// First send of the session: no id yet — the server mints one.
|
|
280
|
-
expect(bodies[0]).toEqual({ messages: [{ role: 'user', content: 'first question' }] })
|
|
281
|
-
// Every later send: NEWEST message only + the echoed id. The client
|
|
282
|
-
// thread (4 messages by now) is never replayed on the wire.
|
|
283
|
-
expect(bodies[1]).toEqual({
|
|
284
|
-
messages: [{ role: 'user', content: 'second question' }],
|
|
285
|
-
conversationId: 'conv-golden-1',
|
|
286
|
-
})
|
|
287
|
-
expect(result.current.messages).toHaveLength(4)
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
it('clearMessages drops the stored id so the next send is id-less (fresh conversation)', async () => {
|
|
291
|
-
const fetchMock = mockRoutedFetch({
|
|
292
|
-
streams: [wireTurn('conv-golden-2', 'Answer.'), wireTurn('conv-golden-3', 'Fresh answer.')],
|
|
293
|
-
})
|
|
294
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
295
|
-
wrapper,
|
|
296
|
-
})
|
|
297
|
-
await act(async () => {
|
|
298
|
-
await result.current.sendMessage('question')
|
|
299
|
-
})
|
|
300
|
-
expect(window.localStorage.getItem(CONVERSATION_KEY)).not.toBeNull()
|
|
301
|
-
|
|
302
|
-
act(() => {
|
|
303
|
-
result.current.clearMessages()
|
|
304
|
-
})
|
|
305
|
-
expect(result.current.messages).toEqual([])
|
|
306
|
-
expect(window.localStorage.getItem(CONVERSATION_KEY)).toBeNull()
|
|
307
|
-
|
|
308
|
-
await act(async () => {
|
|
309
|
-
await result.current.sendMessage('fresh question')
|
|
310
|
-
})
|
|
311
|
-
const bodies = chatBodies(fetchMock)
|
|
312
|
-
expect(bodies[1]).toEqual({ messages: [{ role: 'user', content: 'fresh question' }] })
|
|
313
|
-
// The new conversation's id is re-captured from the fresh stream.
|
|
314
|
-
expect(JSON.parse(window.localStorage.getItem(CONVERSATION_KEY)!)).toEqual({
|
|
315
|
-
conversationId: 'conv-golden-3',
|
|
316
|
-
})
|
|
317
|
-
})
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
describe('server-history hydration (materializes through the reducer)', () => {
|
|
321
|
-
it('rebuilds messages + sendIdx-keyed refs + send counter from the transcript', async () => {
|
|
322
|
-
window.localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ conversationId: 'conv-h1' }))
|
|
323
|
-
const fetchMock = mockRoutedFetch({
|
|
324
|
-
history: HISTORY_PAYLOAD,
|
|
325
|
-
streams: [
|
|
326
|
-
[
|
|
327
|
-
// The live turn after hydration carries sources — they must land
|
|
328
|
-
// at the CONTINUED send index (sendIdx 2), proving the hydrated
|
|
329
|
-
// send counter seeds the reducer's maps.
|
|
330
|
-
'{"sources":[{"index":1,"name":"Pricing overview","path":"docs/pricing.md","documentType":"markdown"}],"conversationId":"conv-h1"}\0',
|
|
331
|
-
'\x1E',
|
|
332
|
-
'Live answer after hydration.',
|
|
333
|
-
'\x1F' + USAGE_END_TRAILER,
|
|
334
|
-
],
|
|
335
|
-
],
|
|
336
|
-
})
|
|
337
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: true }), {
|
|
338
|
-
wrapper,
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
await waitFor(() => expect(result.current.messages).toHaveLength(4))
|
|
342
|
-
expect(result.current.isHydratingHistory).toBe(false)
|
|
343
|
-
|
|
344
|
-
// History fetched by stored conversation id.
|
|
345
|
-
const historyCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/history'))
|
|
346
|
-
expect(String(historyCall![0])).toContain('conversationId=conv-h1')
|
|
347
|
-
|
|
348
|
-
// Full hydrated projection — messages, roles, hidden flags, refs
|
|
349
|
-
// resolved through the reducer's fan-out lookup (send #0's assistant
|
|
350
|
-
// resolves ticket:77; send #1's does not).
|
|
351
|
-
expect(result.current.messages).toMatchSnapshot()
|
|
352
|
-
const assistants = result.current.messages.filter((m) => m.role === 'assistant')
|
|
353
|
-
expect(assistants[0].chatRefs?.['ticket:77']?.id).toBe('77')
|
|
354
|
-
expect(assistants[1].chatRefs).toBeUndefined()
|
|
355
|
-
|
|
356
|
-
// A live send continues the SAME conversation: newest-message-only body
|
|
357
|
-
// with the stored id, and its sources key at the continued send index.
|
|
358
|
-
await act(async () => {
|
|
359
|
-
await result.current.sendMessage('and the enterprise tier?')
|
|
360
|
-
})
|
|
361
|
-
const bodies = chatBodies(fetchMock)
|
|
362
|
-
expect(bodies[0]).toEqual({
|
|
363
|
-
messages: [{ role: 'user', content: 'and the enterprise tier?' }],
|
|
364
|
-
conversationId: 'conv-h1',
|
|
365
|
-
})
|
|
366
|
-
const liveAssistant = result.current.messages[result.current.messages.length - 1]
|
|
367
|
-
expect(liveAssistant.role).toBe('assistant')
|
|
368
|
-
expect(liveAssistant.sources?.[0]?.name).toBe('Pricing overview')
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
it('marks approval placeholders and auto-continuation directives hidden', async () => {
|
|
372
|
-
window.localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ conversationId: 'conv-h2' }))
|
|
373
|
-
mockRoutedFetch({
|
|
374
|
-
history: {
|
|
375
|
-
data: {
|
|
376
|
-
messages: [
|
|
377
|
-
{ seq: 1, role: 'user', content: 'open a ticket', created_at: '2026-07-19T09:00:00.000Z' },
|
|
378
|
-
{ seq: 2, role: 'assistant', content: 'Done.', created_at: '2026-07-19T09:00:05.000Z' },
|
|
379
|
-
// Approval placeholder ('') and the server-built directive are
|
|
380
|
-
// LLM history, never rendered.
|
|
381
|
-
{ seq: 3, role: 'user', content: '', created_at: '2026-07-19T09:00:10.000Z' },
|
|
382
|
-
{
|
|
383
|
-
seq: 4,
|
|
384
|
-
role: 'user',
|
|
385
|
-
content: `${AUTO_CONTINUATION_DIRECTIVE_PREFIX} ask a follow-up.`,
|
|
386
|
-
created_at: '2026-07-19T09:00:11.000Z',
|
|
387
|
-
},
|
|
388
|
-
],
|
|
389
|
-
},
|
|
390
|
-
},
|
|
391
|
-
streams: [],
|
|
392
|
-
})
|
|
393
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: true }), {
|
|
394
|
-
wrapper,
|
|
395
|
-
})
|
|
396
|
-
await waitFor(() => expect(result.current.messages).toHaveLength(4))
|
|
397
|
-
expect(result.current.messages[2].hidden).toBe(true)
|
|
398
|
-
expect(result.current.messages[3].hidden).toBe(true)
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
it('a hydration miss starts the UI empty (server still owns history)', async () => {
|
|
402
|
-
window.localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ conversationId: 'conv-h3' }))
|
|
403
|
-
mockRoutedFetch({ streams: [] }) // history → 404
|
|
404
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: true }), {
|
|
405
|
-
wrapper,
|
|
406
|
-
})
|
|
407
|
-
await act(async () => {
|
|
408
|
-
await tick()
|
|
409
|
-
await tick()
|
|
410
|
-
})
|
|
411
|
-
expect(result.current.messages).toEqual([])
|
|
412
|
-
expect(result.current.isHydratingHistory).toBe(false)
|
|
413
|
-
})
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
describe('leftover PersistedChatState v1 blobs', () => {
|
|
417
|
-
it('neither crash the adapter nor resurrect messages — the blob is swept on mount', () => {
|
|
418
|
-
window.localStorage.setItem(V1_KEY, JSON.stringify(V1_BLOB))
|
|
419
|
-
mockRoutedFetch({ streams: [] })
|
|
420
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
421
|
-
wrapper,
|
|
422
|
-
})
|
|
423
|
-
// v1 history is server-side now — the blob is never read back.
|
|
424
|
-
expect(result.current.messages).toEqual([])
|
|
425
|
-
// …and the mount-time sweep removed it.
|
|
426
|
-
expect(window.localStorage.getItem(V1_KEY)).toBeNull()
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
it('a corrupt v1 blob is equally harmless', () => {
|
|
430
|
-
window.localStorage.setItem(V1_KEY, '{not json')
|
|
431
|
-
mockRoutedFetch({ streams: [] })
|
|
432
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
433
|
-
wrapper,
|
|
434
|
-
})
|
|
435
|
-
expect(result.current.messages).toEqual([])
|
|
436
|
-
expect(window.localStorage.getItem(V1_KEY)).toBeNull()
|
|
437
|
-
})
|
|
438
|
-
})
|