@flamingo-stack/openframe-frontend-core 0.0.458-snapshot.20260720215024 → 0.0.458
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-BLZZXZNM.cjs → chunk-5USSSRAL.cjs} +26 -26
- package/dist/{chunk-BLZZXZNM.cjs.map → chunk-5USSSRAL.cjs.map} +1 -1
- package/dist/{chunk-CGNDAJ7V.js → chunk-6B27O3ZB.js} +4 -4
- package/dist/{chunk-P5JQDFRM.cjs → chunk-7IRPMNRL.cjs} +60 -60
- package/dist/chunk-7IRPMNRL.cjs.map +1 -0
- package/dist/{chunk-4XDWNAZW.cjs → chunk-DQ3EHM6G.cjs} +9 -9
- package/dist/{chunk-4XDWNAZW.cjs.map → chunk-DQ3EHM6G.cjs.map} +1 -1
- package/dist/{chunk-7KSEINAE.cjs → chunk-FTXDIDNX.cjs} +15 -15
- package/dist/{chunk-7KSEINAE.cjs.map → chunk-FTXDIDNX.cjs.map} +1 -1
- package/dist/{chunk-4R6H2U77.js → chunk-H7ETLG65.js} +4 -4
- package/dist/{chunk-LAOO7XHO.js → chunk-HWTUVLYB.js} +2 -2
- package/dist/{chunk-F2NMWV44.js → chunk-IDKDOQZL.js} +2 -2
- package/dist/{chunk-BQ7PMWLM.cjs → chunk-KIIOBK7G.cjs} +27 -28
- package/dist/chunk-KIIOBK7G.cjs.map +1 -0
- package/dist/{chunk-QO2XD3FR.js → chunk-KQGFKG6Y.js} +2 -2
- package/dist/{chunk-3N3TMZ62.cjs → chunk-L676XVP4.cjs} +7 -7
- package/dist/{chunk-3N3TMZ62.cjs.map → chunk-L676XVP4.cjs.map} +1 -1
- package/dist/{chunk-F7F43U7Q.cjs → chunk-LDAOPWXZ.cjs} +11 -11
- package/dist/{chunk-F7F43U7Q.cjs.map → chunk-LDAOPWXZ.cjs.map} +1 -1
- package/dist/{chunk-ADD3FPDG.cjs → chunk-MKLP2SOZ.cjs} +4886 -7004
- package/dist/chunk-MKLP2SOZ.cjs.map +1 -0
- package/dist/{chunk-VXJH5TDN.js → chunk-QL7ODLJA.js} +10 -10
- package/dist/chunk-QL7ODLJA.js.map +1 -0
- package/dist/{chunk-OR5AMXYN.js → chunk-R5QYMUKN.js} +2 -2
- package/dist/{chunk-REWRUV3K.js → chunk-TNLFAJPA.js} +9673 -11791
- package/dist/chunk-TNLFAJPA.js.map +1 -0
- package/dist/{chunk-XF5ROTMQ.js → chunk-U2YDR5DT.js} +2 -2
- package/dist/{chunk-PXZ7YQ5V.cjs → chunk-VTQSB6QB.cjs} +5 -5
- package/dist/{chunk-PXZ7YQ5V.cjs.map → chunk-VTQSB6QB.cjs.map} +1 -1
- package/dist/{chunk-UI7CIUAR.js → chunk-WGJPTFRH.js} +2 -2
- package/dist/{chunk-WCE66C2Z.cjs → chunk-XUNJILJT.cjs} +62 -62
- package/dist/{chunk-WCE66C2Z.cjs.map → chunk-XUNJILJT.cjs.map} +1 -1
- package/dist/{chunk-APF5QBAU.js → chunk-YQBPIUZU.js} +2 -2
- package/dist/{chunk-TA7MFVUS.cjs → chunk-YV2YA3QH.cjs} +31 -31
- package/dist/{chunk-TA7MFVUS.cjs.map → chunk-YV2YA3QH.cjs.map} +1 -1
- package/dist/{chunk-QSGZE6AZ.cjs → chunk-ZDJ4ZUQ4.cjs} +37 -37
- package/dist/{chunk-QSGZE6AZ.cjs.map → chunk-ZDJ4ZUQ4.cjs.map} +1 -1
- package/dist/{chunk-LJKZIIK4.js → chunk-ZHQQRYMB.js} +5 -6
- package/dist/chunk-ZHQQRYMB.js.map +1 -0
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chart.d.ts +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +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 +47 -3
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -10
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +0 -1
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +13 -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/processing.types.d.ts +90 -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/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 +129 -171
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +21 -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 -44
- 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 +13 -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 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -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 +17 -111
- package/src/components/chat/chat-message-list.tsx +113 -367
- package/src/components/chat/embeddable-chat.tsx +0 -6
- package/src/components/chat/hooks/.index.md +1 -1
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +50 -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 +822 -243
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +479 -0
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +666 -318
- package/src/components/chat/index.ts +0 -5
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +3 -1
- package/src/components/chat/types/.processing.types.md +23 -6
- package/src/components/chat/types/api.types.ts +73 -6
- package/src/components/chat/types/processing.types.ts +52 -11
- package/src/components/chat/utils/.chunk-parser.md +51 -0
- package/src/components/chat/utils/.index.md +5 -0
- package/src/components/chat/utils/chunk-parser.ts +255 -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/process-historical-messages.ts +383 -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 +40 -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 +965 -0
- package/src/components/ui/ticket-info-section.tsx +1 -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 -192
- package/dist/chat-protocol/events.d.ts.map +0 -1
- package/dist/chat-protocol/frames.d.ts +0 -206
- package/dist/chat-protocol/frames.d.ts.map +0 -1
- package/dist/chat-protocol/index.cjs +0 -493
- 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 -477
- 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-ADD3FPDG.cjs.map +0 -1
- package/dist/chunk-BQ7PMWLM.cjs.map +0 -1
- package/dist/chunk-LJKZIIK4.js.map +0 -1
- package/dist/chunk-P5JQDFRM.cjs.map +0 -1
- package/dist/chunk-REWRUV3K.js.map +0 -1
- package/dist/chunk-VXJH5TDN.js.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 -56
- 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 -515
- 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 -277
- package/src/chat-protocol/encode.ts +0 -71
- package/src/chat-protocol/env-flag.ts +0 -39
- package/src/chat-protocol/events.ts +0 -228
- package/src/chat-protocol/frames.ts +0 -238
- 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__/persisted-state-v1-golden.test.ts.snap +0 -297
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +0 -272
- 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 -937
- 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 -418
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -599
- 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 -341
- 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-CGNDAJ7V.js.map → chunk-6B27O3ZB.js.map} +0 -0
- /package/dist/{chunk-4R6H2U77.js.map → chunk-H7ETLG65.js.map} +0 -0
- /package/dist/{chunk-LAOO7XHO.js.map → chunk-HWTUVLYB.js.map} +0 -0
- /package/dist/{chunk-F2NMWV44.js.map → chunk-IDKDOQZL.js.map} +0 -0
- /package/dist/{chunk-QO2XD3FR.js.map → chunk-KQGFKG6Y.js.map} +0 -0
- /package/dist/{chunk-OR5AMXYN.js.map → chunk-R5QYMUKN.js.map} +0 -0
- /package/dist/{chunk-XF5ROTMQ.js.map → chunk-U2YDR5DT.js.map} +0 -0
- /package/dist/{chunk-UI7CIUAR.js.map → chunk-WGJPTFRH.js.map} +0 -0
- /package/dist/{chunk-APF5QBAU.js.map → chunk-YQBPIUZU.js.map} +0 -0
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase-0 GOLDEN CHARACTERIZATION TESTS — PersistedChatState v1.
|
|
3
|
-
*
|
|
4
|
-
* The SSE chat adapter persists its conversation under
|
|
5
|
-
* `mingo-chat-<source>-v1` (see `use-sse-chat-adapter.ts`:
|
|
6
|
-
* chatStorageKey / loadPersistedChat / savePersistedChat). This file
|
|
7
|
-
* seeds localStorage with a hand-crafted, REALISTIC v1 blob and pins the
|
|
8
|
-
* exact rehydrated state the hook exposes. This blob is the MIGRATION
|
|
9
|
-
* CONTRACT for the future unified reducer: whatever replaces the three
|
|
10
|
-
* stream-reading layers must restore this v1 shape identically (or
|
|
11
|
-
* migrate it losslessly).
|
|
12
|
-
*
|
|
13
|
-
* Blob contents:
|
|
14
|
-
* - 2 visible user sends (sendCount: 2)
|
|
15
|
-
* - 4 assistant messages — send #0 fans out to main reply (approval
|
|
16
|
-
* card) + post-approve receipt + continuation bubble (with a hidden
|
|
17
|
-
* approval-action user turn in between); send #1 is a plain RAG answer
|
|
18
|
-
* - sendIdx-keyed refs map with a card ref (ticket:77 on send #0)
|
|
19
|
-
* - sendIdx-keyed sources map (pricing source on send #1)
|
|
20
|
-
* - a NON-hidden auto-continuation directive bubble that the loader's
|
|
21
|
-
* forward-migration must flip to hidden:true
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
25
|
-
import { renderHook } from '@testing-library/react'
|
|
26
|
-
import { createElement, type ReactNode } from 'react'
|
|
27
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
28
|
-
import { useSseChatAdapter } from '../use-sse-chat-adapter'
|
|
29
|
-
import { ChatRuntimeContext, type ChatRuntime } from '../../../../contexts/chat-runtime-context'
|
|
30
|
-
import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../../utils/auto-continuation-directive'
|
|
31
|
-
|
|
32
|
-
const SOURCE = 'goldensrc'
|
|
33
|
-
const STORAGE_KEY = `mingo-chat-${SOURCE}-v1`
|
|
34
|
-
|
|
35
|
-
const runtime: ChatRuntime = {
|
|
36
|
-
endpoints: {
|
|
37
|
-
chatStreamUrl: '/api/docs/chat',
|
|
38
|
-
approvalToolUrl: '/api/chat/agent/confirm-tool',
|
|
39
|
-
commandsUrl: '/api/docs/commands',
|
|
40
|
-
buildListUrl: () => null,
|
|
41
|
-
attachmentUploadUrl: '/api/storage/generate-upload-url',
|
|
42
|
-
attachmentViewUrlPrefix: '/api/storage/view/chat-attachments/',
|
|
43
|
-
identityUrl: '/api/chat/identity',
|
|
44
|
-
},
|
|
45
|
-
navigation: { mode: 'host' },
|
|
46
|
-
source: SOURCE,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function wrapper({ children }: { children: ReactNode }) {
|
|
50
|
-
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
51
|
-
return createElement(
|
|
52
|
-
QueryClientProvider,
|
|
53
|
-
{ client: queryClient },
|
|
54
|
-
createElement(ChatRuntimeContext.Provider, { value: runtime }, children),
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Hand-crafted v1 blob — REALISTIC shapes recorded from the live flow.
|
|
60
|
-
* Timestamps are ISO strings on disk (loadPersistedChat revives them to
|
|
61
|
-
* Date objects in place).
|
|
62
|
-
*/
|
|
63
|
-
const V1_BLOB = {
|
|
64
|
-
messages: [
|
|
65
|
-
// ── send #0: "open a ticket" ────────────────────────────────────
|
|
66
|
-
{
|
|
67
|
-
id: 'user-1752915600000',
|
|
68
|
-
role: 'user',
|
|
69
|
-
name: 'You',
|
|
70
|
-
content: 'open a ticket for the broken printer',
|
|
71
|
-
timestamp: '2026-07-19T09:00:00.000Z',
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
// Main reply: preamble text + (resolved) approval card.
|
|
75
|
-
id: 'assistant-1752915600000',
|
|
76
|
-
role: 'assistant',
|
|
77
|
-
name: 'Mingo AI',
|
|
78
|
-
content: [
|
|
79
|
-
{ type: 'text', text: "I'll open a ticket for you." },
|
|
80
|
-
{
|
|
81
|
-
type: 'approval_request',
|
|
82
|
-
data: {
|
|
83
|
-
command: 'Create ticket',
|
|
84
|
-
fields: [
|
|
85
|
-
{ label: 'Subject', value: 'Broken printer' },
|
|
86
|
-
{ label: 'Priority', value: 'High' },
|
|
87
|
-
],
|
|
88
|
-
requestId: 'prop-1',
|
|
89
|
-
approvalType: 'create_ticket',
|
|
90
|
-
},
|
|
91
|
-
status: 'approved',
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
timestamp: '2026-07-19T09:00:05.000Z',
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
// Hidden approval-action turn (content '' — the Approve click).
|
|
98
|
-
id: 'user-1752915610000',
|
|
99
|
-
role: 'user',
|
|
100
|
-
name: 'You',
|
|
101
|
-
content: '',
|
|
102
|
-
hidden: true,
|
|
103
|
-
timestamp: '2026-07-19T09:00:10.000Z',
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
// Post-approve receipt + card marker; chatRefs stamped on-message.
|
|
107
|
-
id: 'assistant-1752915610000',
|
|
108
|
-
role: 'assistant',
|
|
109
|
-
name: 'Mingo AI',
|
|
110
|
-
content: [
|
|
111
|
-
{
|
|
112
|
-
type: 'text',
|
|
113
|
-
text: '✅ Approved — ticket created: [card://ticket:77]\n\nNow, to triage faster: which floor is the printer on?',
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
chatRefs: {
|
|
117
|
-
'ticket:77': {
|
|
118
|
-
type: 'ticket',
|
|
119
|
-
id: '77',
|
|
120
|
-
title: 'Broken printer',
|
|
121
|
-
url: null,
|
|
122
|
-
metadata: { status: 'OPEN' },
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
timestamp: '2026-07-19T09:00:12.000Z',
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
// Auto-continuation directive bubble persisted by an OLD lib version
|
|
129
|
-
// WITHOUT hidden:true — the loader's forward-migration must flip it.
|
|
130
|
-
id: 'user-1752915612000',
|
|
131
|
-
role: 'user',
|
|
132
|
-
name: 'You',
|
|
133
|
-
content: `${AUTO_CONTINUATION_DIRECTIVE_PREFIX} The user just approved create_ticket (ticket #77). Ask diagnostic follow-ups.`,
|
|
134
|
-
timestamp: '2026-07-19T09:00:12.500Z',
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
// Continuation prose for send #0 (third assistant message).
|
|
138
|
-
id: 'assistant-1752915613000',
|
|
139
|
-
role: 'assistant',
|
|
140
|
-
name: 'Mingo AI',
|
|
141
|
-
content: [{ type: 'text', text: 'Also: is the printer showing any error code on its display?' }],
|
|
142
|
-
timestamp: '2026-07-19T09:00:13.000Z',
|
|
143
|
-
},
|
|
144
|
-
// ── send #1: plain RAG answer ───────────────────────────────────
|
|
145
|
-
{
|
|
146
|
-
id: 'user-1752915700000',
|
|
147
|
-
role: 'user',
|
|
148
|
-
name: 'You',
|
|
149
|
-
content: 'what is the pricing?',
|
|
150
|
-
timestamp: '2026-07-19T09:01:40.000Z',
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
id: 'assistant-1752915700000',
|
|
154
|
-
role: 'assistant',
|
|
155
|
-
name: 'Mingo AI',
|
|
156
|
-
content: [
|
|
157
|
-
{ type: 'thinking', text: 'Checking pricing docs…' },
|
|
158
|
-
{ type: 'text', text: 'Pricing starts at $99/month per site.' },
|
|
159
|
-
],
|
|
160
|
-
timestamp: '2026-07-19T09:01:45.000Z',
|
|
161
|
-
},
|
|
162
|
-
],
|
|
163
|
-
// sendIdx-keyed maps (entries arrays, as serialized by savePersistedChat).
|
|
164
|
-
sources: [
|
|
165
|
-
[
|
|
166
|
-
1,
|
|
167
|
-
[
|
|
168
|
-
{
|
|
169
|
-
index: 1,
|
|
170
|
-
name: 'Pricing overview',
|
|
171
|
-
path: 'docs/pricing.md',
|
|
172
|
-
documentType: 'markdown',
|
|
173
|
-
sourceRepo: 'markdown',
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
],
|
|
177
|
-
],
|
|
178
|
-
refs: [
|
|
179
|
-
[
|
|
180
|
-
0,
|
|
181
|
-
{
|
|
182
|
-
'ticket:77': {
|
|
183
|
-
type: 'ticket',
|
|
184
|
-
id: '77',
|
|
185
|
-
title: 'Broken printer',
|
|
186
|
-
url: null,
|
|
187
|
-
metadata: { status: 'OPEN' },
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
],
|
|
191
|
-
],
|
|
192
|
-
sendCount: 2,
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
beforeEach(() => {
|
|
196
|
-
window.localStorage.clear()
|
|
197
|
-
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
afterEach(() => {
|
|
201
|
-
vi.restoreAllMocks()
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
describe('PersistedChatState v1 — golden rehydration (migration contract)', () => {
|
|
205
|
-
it('restores messages / refs / sources from a hand-crafted v1 blob (snapshot)', () => {
|
|
206
|
-
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(V1_BLOB))
|
|
207
|
-
|
|
208
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
209
|
-
wrapper,
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
// Pin the FULL rehydrated public state:
|
|
213
|
-
// - hidden approval-action turn + migrated directive bubble carry hidden:true
|
|
214
|
-
// - all THREE assistant messages of send #0 resolve chatRefs['ticket:77']
|
|
215
|
-
// (message-bound copy on the receipt, refs-map fallback for the others)
|
|
216
|
-
// - send #1's assistant message carries the sources array
|
|
217
|
-
expect({
|
|
218
|
-
messages: result.current.messages,
|
|
219
|
-
streamingPhase: result.current.streamingPhase,
|
|
220
|
-
isLoading: result.current.isLoading,
|
|
221
|
-
}).toMatchSnapshot()
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
it('forward-migration flips a non-hidden auto-continuation directive bubble to hidden', () => {
|
|
225
|
-
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(V1_BLOB))
|
|
226
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
227
|
-
wrapper,
|
|
228
|
-
})
|
|
229
|
-
const directive = result.current.messages.find((m) =>
|
|
230
|
-
m.content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX),
|
|
231
|
-
)
|
|
232
|
-
expect(directive?.hidden).toBe(true)
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
it('hidden user turns do NOT advance the sendIdx used for sources/refs lookup', () => {
|
|
236
|
-
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(V1_BLOB))
|
|
237
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
238
|
-
wrapper,
|
|
239
|
-
})
|
|
240
|
-
const assistants = result.current.messages.filter((m) => m.role === 'assistant')
|
|
241
|
-
// Send #0 fans out into THREE assistant bubbles (main reply +
|
|
242
|
-
// post-approve receipt + continuation); send #1 adds a fourth.
|
|
243
|
-
expect(assistants).toHaveLength(4)
|
|
244
|
-
// All send-#0 assistant bubbles resolve the ticket ref…
|
|
245
|
-
expect(assistants[0].chatRefs?.['ticket:77']?.id).toBe('77')
|
|
246
|
-
expect(assistants[1].chatRefs?.['ticket:77']?.id).toBe('77')
|
|
247
|
-
expect(assistants[2].chatRefs?.['ticket:77']?.id).toBe('77')
|
|
248
|
-
// …and the send-#1 bubble gets the pricing sources instead.
|
|
249
|
-
expect(assistants[3].sources?.[0]?.name).toBe('Pricing overview')
|
|
250
|
-
expect(assistants[3].chatRefs).toBeUndefined()
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
it('a corrupt blob falls back to an empty conversation', () => {
|
|
254
|
-
window.localStorage.setItem(STORAGE_KEY, '{not json')
|
|
255
|
-
const { result } = renderHook(() => useSseChatAdapter(undefined, { active: false }), {
|
|
256
|
-
wrapper,
|
|
257
|
-
})
|
|
258
|
-
expect(result.current.messages).toEqual([])
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
it('rehydration round-trips: the persist callback re-saves an equivalent v1 blob', () => {
|
|
262
|
-
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(V1_BLOB))
|
|
263
|
-
renderHook(() => useSseChatAdapter(undefined, { active: false }), { wrapper })
|
|
264
|
-
|
|
265
|
-
// Mounting fires onMessagesChange → savePersistedChat with the loaded
|
|
266
|
-
// state (timestamps re-serialized to ISO strings, directive bubble now
|
|
267
|
-
// hidden:true). Pin the re-saved wire shape — THIS is the exact v1 blob
|
|
268
|
-
// a future reducer will find on disk.
|
|
269
|
-
const resaved = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!)
|
|
270
|
-
expect(resaved).toMatchSnapshot()
|
|
271
|
-
})
|
|
272
|
-
})
|
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase-0 GOLDEN CHARACTERIZATION TESTS — the SSE wire decoder inside
|
|
3
|
-
* `useSseChatAdapter` (`createDocStreamFn`), driven through the full
|
|
4
|
-
* public hook (`useSseChatAdapter` → `useChat` → `useSSE`).
|
|
5
|
-
*
|
|
6
|
-
* Wire format (recorded from the hub's /api/docs/chat):
|
|
7
|
-
* [JSON frame]\0 [JSON frame]\0 … \x1E <raw UTF-8 answer deltas> \x1F [JSON usage trailer]
|
|
8
|
-
*
|
|
9
|
-
* Each fixture feeds a mocked global fetch whose Response body is a
|
|
10
|
-
* ReadableStream of Uint8Array chunks, then snapshots the resulting
|
|
11
|
-
* message state. Behaviors captured here — INCLUDING the sentinel-byte
|
|
12
|
-
* mis-framing in fixture (d) — are the recorded baseline for the
|
|
13
|
-
* unification reducer. Do NOT "fix" them here.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
17
|
-
import { renderHook, act } from '@testing-library/react'
|
|
18
|
-
import { createElement, type ReactNode } from 'react'
|
|
19
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
20
|
-
import { useSseChatAdapter } from '../use-sse-chat-adapter'
|
|
21
|
-
import { ChatRuntimeContext, type ChatRuntime } from '../../../../contexts/chat-runtime-context'
|
|
22
|
-
|
|
23
|
-
// =============================================================================
|
|
24
|
-
// Harness
|
|
25
|
-
// =============================================================================
|
|
26
|
-
|
|
27
|
-
const SOURCE = 'goldensrc'
|
|
28
|
-
|
|
29
|
-
const runtime: ChatRuntime = {
|
|
30
|
-
endpoints: {
|
|
31
|
-
chatStreamUrl: '/api/docs/chat',
|
|
32
|
-
approvalToolUrl: '/api/chat/agent/confirm-tool',
|
|
33
|
-
commandsUrl: '/api/docs/commands',
|
|
34
|
-
buildListUrl: () => null,
|
|
35
|
-
attachmentUploadUrl: '/api/storage/generate-upload-url',
|
|
36
|
-
attachmentViewUrlPrefix: '/api/storage/view/chat-attachments/',
|
|
37
|
-
identityUrl: '/api/chat/identity',
|
|
38
|
-
},
|
|
39
|
-
navigation: { mode: 'host' },
|
|
40
|
-
source: SOURCE,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function wrapper({ children }: { children: ReactNode }) {
|
|
44
|
-
const queryClient = new QueryClient({
|
|
45
|
-
defaultOptions: { queries: { retry: false } },
|
|
46
|
-
})
|
|
47
|
-
return createElement(
|
|
48
|
-
QueryClientProvider,
|
|
49
|
-
{ client: queryClient },
|
|
50
|
-
createElement(ChatRuntimeContext.Provider, { value: runtime }, children),
|
|
51
|
-
)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** renderHook with the required runtime + react-query providers.
|
|
55
|
-
* `active: false` skips the slash-command registry fetch so the mocked
|
|
56
|
-
* fetch only ever serves the chat stream. */
|
|
57
|
-
function renderAdapter() {
|
|
58
|
-
return renderHook(() => useSseChatAdapter(undefined, { active: false }), { wrapper })
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const enc = new TextEncoder()
|
|
62
|
-
|
|
63
|
-
/** Build a ReadableStream that enqueues each chunk (string → UTF-8) then closes. */
|
|
64
|
-
function streamOf(chunks: Array<string | Uint8Array>): ReadableStream<Uint8Array> {
|
|
65
|
-
return new ReadableStream<Uint8Array>({
|
|
66
|
-
start(controller) {
|
|
67
|
-
for (const c of chunks) {
|
|
68
|
-
controller.enqueue(typeof c === 'string' ? enc.encode(c) : c)
|
|
69
|
-
}
|
|
70
|
-
controller.close()
|
|
71
|
-
},
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Mock global fetch to serve ONE streamed chat response. */
|
|
76
|
-
function mockFetchStream(chunks: Array<string | Uint8Array>) {
|
|
77
|
-
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
78
|
-
const body = streamOf(chunks)
|
|
79
|
-
// Honor the caller's AbortSignal like real fetch: error the stream so
|
|
80
|
-
// pending reader.read() calls reject with AbortError.
|
|
81
|
-
return { ok: true, status: 200, body } as unknown as Response
|
|
82
|
-
})
|
|
83
|
-
vi.stubGlobal('fetch', fetchMock)
|
|
84
|
-
return fetchMock
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const tick = () => new Promise<void>((r) => setTimeout(r, 0))
|
|
88
|
-
|
|
89
|
-
beforeEach(() => {
|
|
90
|
-
window.localStorage.clear()
|
|
91
|
-
// Deterministic Date.now: message ids (`user-<now>`) and the 50ms
|
|
92
|
-
// thinking-throttle both read it. With a frozen clock the FIRST
|
|
93
|
-
// thinking delta yields immediately (now - 0 >= 50) and every later
|
|
94
|
-
// delta within the same stream is throttled (now - last = 0 < 50) and
|
|
95
|
-
// flushed at the \x1E boundary — fully deterministic.
|
|
96
|
-
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
afterEach(() => {
|
|
100
|
-
vi.unstubAllGlobals()
|
|
101
|
-
vi.restoreAllMocks()
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
/** Compact projection of the adapter's public state for snapshots. */
|
|
105
|
-
function snapshotState(result: { current: ReturnType<typeof useSseChatAdapter> }) {
|
|
106
|
-
const s = result.current
|
|
107
|
-
return {
|
|
108
|
-
messages: s.messages,
|
|
109
|
-
streamingPhase: s.streamingPhase,
|
|
110
|
-
provider: s.currentProvider,
|
|
111
|
-
modelLabel: s.currentModelLabel,
|
|
112
|
-
contextWindowMaxTokens: s.currentContextWindowMaxTokens,
|
|
113
|
-
inputTokens: s.currentInputTokens,
|
|
114
|
-
outputTokens: s.currentOutputTokens,
|
|
115
|
-
cacheHitRatePct: s.currentCacheHitRatePct,
|
|
116
|
-
usageBreakdown: s.currentUsageBreakdown,
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Shared leading-frame fixtures (recorded wire shapes).
|
|
121
|
-
const THINKING_STATUS = '{"status":"thinking"}\0'
|
|
122
|
-
const THINKING_DELTA_1 = '{"kind":"thinking-delta","text":"Let me check the docs… "}\0'
|
|
123
|
-
const THINKING_DELTA_2 = '{"kind":"thinking-delta","text":"found it."}\0'
|
|
124
|
-
const METADATA_FRAME =
|
|
125
|
-
'{"sources":[{"index":1,"name":"Pricing overview","path":"docs/pricing.md","documentType":"markdown","sourceRepo":"markdown"}],"model":"claude-sonnet-x","modelLabel":"Claude Sonnet","provider":"anthropic","contextWindowMaxTokens":200000}\0'
|
|
126
|
-
const ROUTING_FRAME = '{"kind":"routing","routedComplexity":"simple","routedThinkingBudget":0}\0'
|
|
127
|
-
const USAGE_START_FRAME = '{"kind":"usage","stage":"start","input_tokens":1234}\0'
|
|
128
|
-
const USAGE_END_TRAILER =
|
|
129
|
-
'{"kind":"usage","stage":"end","input_tokens":1234,"output_tokens":56,"hit_rate_pct":81.5,"breakdown":{"haikuRewriter":{"input":10,"output":4},"haikuClassifier":{"input":8,"output":1},"routedAnswer":{"model":"claude-sonnet-x","complexity":"simple","thinkingBudget":0}}}'
|
|
130
|
-
|
|
131
|
-
describe('SSE wire decode — golden fixtures (full hook path)', () => {
|
|
132
|
-
it('(a) normal turn: thinking frames → metadata → usage:start → \\x1E → text ×3 → \\x1F trailer', async () => {
|
|
133
|
-
mockFetchStream([
|
|
134
|
-
THINKING_STATUS,
|
|
135
|
-
THINKING_DELTA_1,
|
|
136
|
-
THINKING_DELTA_2,
|
|
137
|
-
METADATA_FRAME,
|
|
138
|
-
ROUTING_FRAME,
|
|
139
|
-
USAGE_START_FRAME,
|
|
140
|
-
'\x1E',
|
|
141
|
-
'Answer part one, ',
|
|
142
|
-
'part two, ',
|
|
143
|
-
'part three.',
|
|
144
|
-
'\x1F' + USAGE_END_TRAILER,
|
|
145
|
-
])
|
|
146
|
-
const { result } = renderAdapter()
|
|
147
|
-
await act(async () => {
|
|
148
|
-
await result.current.sendMessage('what is the pricing?')
|
|
149
|
-
})
|
|
150
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('(b) EVERYTHING in one single TCP chunk', async () => {
|
|
154
|
-
mockFetchStream([
|
|
155
|
-
THINKING_STATUS +
|
|
156
|
-
THINKING_DELTA_1 +
|
|
157
|
-
METADATA_FRAME +
|
|
158
|
-
USAGE_START_FRAME +
|
|
159
|
-
'\x1E' +
|
|
160
|
-
'One-chunk answer.' +
|
|
161
|
-
'\x1F' +
|
|
162
|
-
USAGE_END_TRAILER,
|
|
163
|
-
])
|
|
164
|
-
const { result } = renderAdapter()
|
|
165
|
-
await act(async () => {
|
|
166
|
-
await result.current.sendMessage('one chunk please')
|
|
167
|
-
})
|
|
168
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('(c) trailer split across two chunks arriving with stream done', async () => {
|
|
172
|
-
mockFetchStream([
|
|
173
|
-
METADATA_FRAME,
|
|
174
|
-
'\x1E',
|
|
175
|
-
'Split-trailer answer.',
|
|
176
|
-
'\x1F{"kind":"usage","stage":"end","input_to',
|
|
177
|
-
'kens":42,"output_tokens":9,"hit_rate_pct":12.5}',
|
|
178
|
-
])
|
|
179
|
-
const { result } = renderAdapter()
|
|
180
|
-
await act(async () => {
|
|
181
|
-
await result.current.sendMessage('split the trailer')
|
|
182
|
-
})
|
|
183
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
it('(d) answer text containing literal \\x1E/\\x1F/\\0 bytes', async () => {
|
|
187
|
-
// CHARACTERIZATION: current behavior, see unification plan.
|
|
188
|
-
// - In text mode only \x1F is scanned: literal \0 and \x1E bytes in
|
|
189
|
-
// the answer pass through into the rendered text unchanged.
|
|
190
|
-
// - The FIRST literal \x1F flips the decoder into trailer mode:
|
|
191
|
-
// everything after it (including later real text) is captured as
|
|
192
|
-
// the trailer buffer, fails JSON.parse, and is SILENTLY DROPPED —
|
|
193
|
-
// the real \x1F usage trailer is lost too (mis-framing/truncation).
|
|
194
|
-
mockFetchStream([
|
|
195
|
-
METADATA_FRAME,
|
|
196
|
-
'\x1E',
|
|
197
|
-
'has-nul[\0]and-rs[\x1E]still-text. ',
|
|
198
|
-
'now-us[\x1F]everything-after-is-swallowed',
|
|
199
|
-
' including this and the real trailer:',
|
|
200
|
-
'\x1F' + USAGE_END_TRAILER,
|
|
201
|
-
])
|
|
202
|
-
const { result } = renderAdapter()
|
|
203
|
-
await act(async () => {
|
|
204
|
-
await result.current.sendMessage('sentinel bytes in answer')
|
|
205
|
-
})
|
|
206
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
it('(e) [card://blog:x] marker in the answer with refs metadata frame', async () => {
|
|
210
|
-
const refsFrame =
|
|
211
|
-
'{"refs":{"blog:x":{"type":"blog","id":"x","title":"Post X","url":"/blog/x","metadata":{"slug":"post-x"}}}}\0'
|
|
212
|
-
mockFetchStream([
|
|
213
|
-
METADATA_FRAME,
|
|
214
|
-
refsFrame,
|
|
215
|
-
'\x1E',
|
|
216
|
-
'See [card://blog:x] for the full story.',
|
|
217
|
-
'\x1F' + USAGE_END_TRAILER,
|
|
218
|
-
])
|
|
219
|
-
const { result } = renderAdapter()
|
|
220
|
-
await act(async () => {
|
|
221
|
-
await result.current.sendMessage('show me the post')
|
|
222
|
-
})
|
|
223
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('(f) abort mid-stream via AbortController leaves the partial message in place', async () => {
|
|
227
|
-
// Manually-controlled stream: enqueue the leading frames + first text
|
|
228
|
-
// delta, keep the stream OPEN, then Stop. The mocked fetch honors the
|
|
229
|
-
// AbortSignal by erroring the stream with AbortError — same observable
|
|
230
|
-
// behavior as real fetch cancelling the body.
|
|
231
|
-
let ctrl!: ReadableStreamDefaultController<Uint8Array>
|
|
232
|
-
const body = new ReadableStream<Uint8Array>({
|
|
233
|
-
start(c) {
|
|
234
|
-
ctrl = c
|
|
235
|
-
c.enqueue(enc.encode(METADATA_FRAME))
|
|
236
|
-
c.enqueue(enc.encode('\x1E'))
|
|
237
|
-
c.enqueue(enc.encode('Partial ans'))
|
|
238
|
-
},
|
|
239
|
-
})
|
|
240
|
-
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
241
|
-
init?.signal?.addEventListener('abort', () => {
|
|
242
|
-
try {
|
|
243
|
-
ctrl.error(new DOMException('The operation was aborted.', 'AbortError'))
|
|
244
|
-
} catch {
|
|
245
|
-
/* already closed */
|
|
246
|
-
}
|
|
247
|
-
})
|
|
248
|
-
return { ok: true, status: 200, body } as unknown as Response
|
|
249
|
-
})
|
|
250
|
-
vi.stubGlobal('fetch', fetchMock)
|
|
251
|
-
|
|
252
|
-
const { result } = renderAdapter()
|
|
253
|
-
let sendPromise!: Promise<void>
|
|
254
|
-
act(() => {
|
|
255
|
-
sendPromise = result.current.sendMessage('will be aborted')
|
|
256
|
-
})
|
|
257
|
-
// Let the decoder consume the enqueued chunks.
|
|
258
|
-
await act(async () => {
|
|
259
|
-
await tick()
|
|
260
|
-
await tick()
|
|
261
|
-
await tick()
|
|
262
|
-
})
|
|
263
|
-
act(() => {
|
|
264
|
-
result.current.stopMessage()
|
|
265
|
-
})
|
|
266
|
-
await act(async () => {
|
|
267
|
-
await sendPromise
|
|
268
|
-
})
|
|
269
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
270
|
-
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
it('(g) multi-byte UTF-8 character split across two chunks decodes intact', async () => {
|
|
274
|
-
// '🦩' = F0 9F A6 A9 — split 2+2 across chunk boundary. The decoder is
|
|
275
|
-
// created once per stream with {stream:true} reads, so the glyph
|
|
276
|
-
// survives the split. CHARACTERIZATION: current (correct) behavior.
|
|
277
|
-
const flamingo = enc.encode('🦩')
|
|
278
|
-
mockFetchStream([
|
|
279
|
-
METADATA_FRAME,
|
|
280
|
-
'\x1E',
|
|
281
|
-
'A wild ',
|
|
282
|
-
flamingo.slice(0, 2),
|
|
283
|
-
flamingo.slice(2),
|
|
284
|
-
' appears',
|
|
285
|
-
'\x1F' + USAGE_END_TRAILER,
|
|
286
|
-
])
|
|
287
|
-
const { result } = renderAdapter()
|
|
288
|
-
await act(async () => {
|
|
289
|
-
await result.current.sendMessage('emoji split')
|
|
290
|
-
})
|
|
291
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
it('leading approval_request + text-leading frames yield a pending approval card', async () => {
|
|
295
|
-
mockFetchStream([
|
|
296
|
-
'{"kind":"text-leading","text":"I\'ll open a ticket for you."}\0',
|
|
297
|
-
'{"kind":"approval_request","proposalId":"prop-1","toolName":"create_ticket","title":"Create ticket","fields":[{"label":"Subject","value":"Printer down"},{"label":"Priority","value":"High"},{"bad":"missing label/value"}]}\0',
|
|
298
|
-
// Stream closes with NO \x1E answer body — the approval turn ends here.
|
|
299
|
-
])
|
|
300
|
-
const { result } = renderAdapter()
|
|
301
|
-
await act(async () => {
|
|
302
|
-
await result.current.sendMessage('open a ticket')
|
|
303
|
-
})
|
|
304
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
it('non-JSON leading buffer falls through to answer text (legacy no-frame stream)', async () => {
|
|
308
|
-
// CHARACTERIZATION: a frame-less response whose first \0-terminated
|
|
309
|
-
// block is not JSON flips straight into text mode and the buffer is
|
|
310
|
-
// emitted as answer text.
|
|
311
|
-
mockFetchStream(['plain text answer with no frames\0and more after the nul'])
|
|
312
|
-
const { result } = renderAdapter()
|
|
313
|
-
await act(async () => {
|
|
314
|
-
await result.current.sendMessage('legacy stream')
|
|
315
|
-
})
|
|
316
|
-
expect(snapshotState(result)).toMatchSnapshot()
|
|
317
|
-
})
|
|
318
|
-
})
|