@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,894 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase-3 tests — `createChatStreamReducer`, the master chat-stream reader.
|
|
3
|
-
*
|
|
4
|
-
* The golden characterization suites (chunk-processor / SSE-stream /
|
|
5
|
-
* accumulator / history / persisted-v1) pin transport parity through the
|
|
6
|
-
* public hooks; THIS file pins the reducer-specific contracts the plan
|
|
7
|
-
* added on top:
|
|
8
|
-
*
|
|
9
|
-
* - seq-based idempotency (drop `seq` ≤ last applied, per instance);
|
|
10
|
-
* - REFERENTIAL STABILITY: untouched messages keep Object.is identity
|
|
11
|
-
* across applies AND across replayed duplicate events (value-level
|
|
12
|
-
* no-op merges return prior references);
|
|
13
|
-
* - `resolvePendingApprovalForExecution` (implicit approve-on-execution)
|
|
14
|
-
* is a NATS-only semantic — the SSE kernel never triggers it.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
18
|
-
import {
|
|
19
|
-
createChatStreamReducer,
|
|
20
|
-
OWN_ECHO_AUTHOR_TTL_MS,
|
|
21
|
-
OWN_ECHO_TTL_MS,
|
|
22
|
-
} from '../chat-stream-reducer'
|
|
23
|
-
import type { ChatStreamEvent } from '../../../../chat-protocol/events'
|
|
24
|
-
|
|
25
|
-
const executing = (execId = 'exec-1', seq?: number): ChatStreamEvent => ({
|
|
26
|
-
type: 'tool-execution',
|
|
27
|
-
data: {
|
|
28
|
-
type: 'EXECUTING_TOOL',
|
|
29
|
-
integratedToolType: 'SHELL',
|
|
30
|
-
toolFunction: 'run_command',
|
|
31
|
-
toolTitle: 'Run command',
|
|
32
|
-
parameters: { cmd: 'ls' },
|
|
33
|
-
toolExecutionRequestId: execId,
|
|
34
|
-
},
|
|
35
|
-
...(seq != null ? { seq } : {}),
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
const executed = (execId = 'exec-1', seq?: number): ChatStreamEvent => ({
|
|
39
|
-
type: 'tool-execution',
|
|
40
|
-
data: {
|
|
41
|
-
type: 'EXECUTED_TOOL',
|
|
42
|
-
integratedToolType: 'SHELL',
|
|
43
|
-
toolFunction: 'run_command',
|
|
44
|
-
result: 'ok',
|
|
45
|
-
success: true,
|
|
46
|
-
toolExecutionRequestId: execId,
|
|
47
|
-
},
|
|
48
|
-
...(seq != null ? { seq } : {}),
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
describe('createChatStreamReducer — seq idempotency', () => {
|
|
52
|
-
it('drops events whose seq is ≤ the last applied seq', () => {
|
|
53
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
54
|
-
r.apply({ type: 'turn-start', seq: 1 })
|
|
55
|
-
r.apply({ type: 'text-delta', text: 'a', seq: 2 })
|
|
56
|
-
r.apply({ type: 'text-delta', text: 'b', seq: 3 })
|
|
57
|
-
// Redelivery of seq 3 and an out-of-order seq 2 must both be dropped.
|
|
58
|
-
r.apply({ type: 'text-delta', text: 'b', seq: 3 })
|
|
59
|
-
r.apply({ type: 'text-delta', text: 'a', seq: 2 })
|
|
60
|
-
const last = r.state.messages[r.state.messages.length - 1]
|
|
61
|
-
expect(last.segments).toEqual([{ type: 'text', text: 'ab' }])
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('seq-less events are never gated', () => {
|
|
65
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
66
|
-
r.apply({ type: 'turn-start', seq: 5 })
|
|
67
|
-
r.apply({ type: 'text-delta', text: 'x' })
|
|
68
|
-
r.apply({ type: 'text-delta', text: 'x' })
|
|
69
|
-
const last = r.state.messages[r.state.messages.length - 1]
|
|
70
|
-
expect(last.segments).toEqual([{ type: 'text', text: 'xx' }])
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
describe('createChatStreamReducer — referential stability', () => {
|
|
75
|
-
it('applies reclone ONLY the touched message; earlier messages keep identity', () => {
|
|
76
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
77
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'hello', seq: 1 })
|
|
78
|
-
r.apply({ type: 'turn-start', seq: 2 })
|
|
79
|
-
r.apply({ type: 'text-delta', text: 'first reply', seq: 3 })
|
|
80
|
-
r.apply({ type: 'turn-end', seq: 4 })
|
|
81
|
-
const [userBefore, assistantBefore] = r.state.messages
|
|
82
|
-
|
|
83
|
-
// A post-END continuation appends into the trailing assistant only.
|
|
84
|
-
r.apply({ type: 'text-delta', text: ' …more', seq: 5 })
|
|
85
|
-
const [userAfter, assistantAfter] = r.state.messages
|
|
86
|
-
expect(userAfter).toBe(userBefore)
|
|
87
|
-
expect(assistantAfter).not.toBe(assistantBefore)
|
|
88
|
-
expect(assistantAfter.segments).toEqual([{ type: 'text', text: 'first reply …more' }])
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('a replayed duplicate EXECUTED event (same seq) is dropped — full state identity holds', () => {
|
|
92
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
93
|
-
r.apply({ type: 'turn-start', seq: 1 })
|
|
94
|
-
r.apply({ type: 'text-delta', text: 'running', seq: 2 })
|
|
95
|
-
r.apply({ type: 'turn-end', seq: 3 })
|
|
96
|
-
r.apply(executing('exec-1', 4))
|
|
97
|
-
r.apply(executed('exec-1', 5))
|
|
98
|
-
const messagesBefore = r.state.messages
|
|
99
|
-
|
|
100
|
-
r.apply(executed('exec-1', 5)) // JetStream redelivery
|
|
101
|
-
expect(r.state.messages).toBe(messagesBefore)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('a replayed seq-less duplicate EXECUTED merge is a value-level no-op — prior references returned', () => {
|
|
105
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
106
|
-
r.apply({ type: 'turn-start' })
|
|
107
|
-
r.apply({ type: 'text-delta', text: 'running' })
|
|
108
|
-
r.apply({ type: 'turn-end' })
|
|
109
|
-
r.apply(executing())
|
|
110
|
-
r.apply(executed())
|
|
111
|
-
const messagesBefore = r.state.messages
|
|
112
|
-
const trailingBefore = messagesBefore[messagesBefore.length - 1]
|
|
113
|
-
|
|
114
|
-
// Same values, new event object, no seq (plain-NATS replay).
|
|
115
|
-
r.apply(executed())
|
|
116
|
-
const messagesAfter = r.state.messages
|
|
117
|
-
expect(messagesAfter).toBe(messagesBefore)
|
|
118
|
-
expect(messagesAfter[messagesAfter.length - 1]).toBe(trailingBefore)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('a redelivered EXECUTING after EXECUTED never downgrades (prior references returned)', () => {
|
|
122
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
123
|
-
r.apply(executing())
|
|
124
|
-
r.apply(executed())
|
|
125
|
-
const before = r.state.messages
|
|
126
|
-
r.apply(executing())
|
|
127
|
-
expect(r.state.messages).toBe(before)
|
|
128
|
-
})
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
describe('createChatStreamReducer — implicit approve-on-execution transport gate', () => {
|
|
132
|
-
const approvalRequest: ChatStreamEvent = {
|
|
133
|
-
type: 'approval-request',
|
|
134
|
-
requestId: 'req-1',
|
|
135
|
-
approvalType: 'CLIENT',
|
|
136
|
-
command: 'systemctl restart nats',
|
|
137
|
-
explanation: 'Restart the broker',
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
it("transport 'nats': an in-stream tool execution implicitly approves the pending gate", () => {
|
|
141
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
142
|
-
r.apply({ type: 'turn-start' })
|
|
143
|
-
r.apply(approvalRequest)
|
|
144
|
-
// Observer stream: EXECUTING arrives with NO APPROVAL_RESULT chunk.
|
|
145
|
-
r.apply(executing())
|
|
146
|
-
const trailing = r.state.messages[r.state.messages.length - 1]
|
|
147
|
-
const card = trailing.segments?.find((s) => s.type === 'approval_request')
|
|
148
|
-
expect(card).toBeDefined()
|
|
149
|
-
expect((card as { status?: string }).status).toBe('approved')
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it("transport 'sse': tool-execution events are ignored — a pending card stays pending", () => {
|
|
153
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
154
|
-
r.beginSseSend({ text: 'open a ticket', assistantName: 'Mingo AI' })
|
|
155
|
-
r.apply({
|
|
156
|
-
type: 'approval-request',
|
|
157
|
-
requestId: 'prop-1',
|
|
158
|
-
approvalType: 'create_ticket',
|
|
159
|
-
command: 'Create ticket',
|
|
160
|
-
fields: [{ label: 'Subject', value: 'Printer down' }],
|
|
161
|
-
})
|
|
162
|
-
const before = r.state.messages
|
|
163
|
-
r.apply(executing())
|
|
164
|
-
// Ignored entirely: same references, card untouched.
|
|
165
|
-
expect(r.state.messages).toBe(before)
|
|
166
|
-
const trailing = r.state.messages[r.state.messages.length - 1]
|
|
167
|
-
const card = trailing.segments?.find((s) => s.type === 'approval_request')
|
|
168
|
-
expect((card as { status?: string }).status).toBe('pending')
|
|
169
|
-
})
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
describe('createChatStreamReducer — SSE kernel', () => {
|
|
173
|
-
it('accumulates text cumulatively, keeps one thinking segment at the front, escapes tags', () => {
|
|
174
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
175
|
-
r.beginSseSend({ text: 'q', assistantName: 'Mingo AI' })
|
|
176
|
-
r.apply({ type: 'thinking-delta', text: 'checking <docs>… ' })
|
|
177
|
-
r.apply({ type: 'turn-start' })
|
|
178
|
-
r.apply({ type: 'text-delta', text: 'Answer ' })
|
|
179
|
-
r.apply({ type: 'thinking-delta', text: 'found it.' })
|
|
180
|
-
r.apply({ type: 'text-delta', text: 'body.' })
|
|
181
|
-
const trailing = r.state.messages[r.state.messages.length - 1]
|
|
182
|
-
expect(trailing.segments).toEqual([
|
|
183
|
-
{ type: 'thinking', text: 'checking <docs>… found it.' },
|
|
184
|
-
{ type: 'text', text: 'Answer body.' },
|
|
185
|
-
])
|
|
186
|
-
expect(r.state.streamingPhase).toBe('streaming')
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
it('decision_resolved flips the SOURCE card, writes the receipt, and stamps chatRefs', () => {
|
|
190
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
191
|
-
// Turn 1: approval card.
|
|
192
|
-
r.beginSseSend({ text: 'open a ticket', assistantName: 'Mingo AI' })
|
|
193
|
-
r.apply({
|
|
194
|
-
type: 'approval-request',
|
|
195
|
-
requestId: 'prop-1',
|
|
196
|
-
approvalType: 'create_ticket',
|
|
197
|
-
command: 'Create ticket',
|
|
198
|
-
fields: [],
|
|
199
|
-
})
|
|
200
|
-
r.endSseTurn()
|
|
201
|
-
// Turn 2: hidden approval-action send → decision_resolved frame.
|
|
202
|
-
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
|
|
203
|
-
r.apply({
|
|
204
|
-
type: 'approval-resolved',
|
|
205
|
-
requestId: 'prop-1',
|
|
206
|
-
status: 'approved',
|
|
207
|
-
ok: true,
|
|
208
|
-
receiptText: '✅ Approved — ticket created: [card://ticket:77]',
|
|
209
|
-
cardRef: { type: 'ticket', id: '77', title: 'Broken printer' },
|
|
210
|
-
cardType: 'ticket',
|
|
211
|
-
})
|
|
212
|
-
r.endSseTurn()
|
|
213
|
-
|
|
214
|
-
// Three rows, not four: the hidden approval-action send is out-of-band
|
|
215
|
-
// metadata and mints NO user row (it would render as a bare author label).
|
|
216
|
-
expect(r.state.messages).toHaveLength(3)
|
|
217
|
-
const [, sourceMsg, receiptMsg] = r.state.messages
|
|
218
|
-
const card = sourceMsg.segments?.find((s) => s.type === 'approval_request')
|
|
219
|
-
expect((card as { status?: string }).status).toBe('approved')
|
|
220
|
-
expect(receiptMsg.segments).toEqual([
|
|
221
|
-
{ type: 'text', text: '✅ Approved — ticket created: [card://ticket:77]\n\n' },
|
|
222
|
-
])
|
|
223
|
-
expect(receiptMsg.chatRefs?.['ticket:77']?.id).toBe('77')
|
|
224
|
-
// Per-send refs map got the ref too (sendIdx 1 — the hidden send).
|
|
225
|
-
expect(r.state.turnMeta.refs.get(1)?.['ticket:77']?.id).toBe('77')
|
|
226
|
-
expect(r.state.streamingPhase).toBe('idle')
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
it('endSseTurn drops an empty trailing placeholder (reject path)', () => {
|
|
230
|
-
const r = createChatStreamReducer({ transport: 'sse' })
|
|
231
|
-
r.beginSseSend({ text: 'q', assistantName: 'Mingo AI' })
|
|
232
|
-
r.apply({ type: 'text-delta', text: 'answer' })
|
|
233
|
-
r.endSseTurn()
|
|
234
|
-
// Reject: an empty-text (approval-action) send adds ONLY the assistant
|
|
235
|
-
// placeholder…
|
|
236
|
-
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
|
|
237
|
-
expect(r.state.messages).toHaveLength(3)
|
|
238
|
-
expect(r.state.messages[2].role).toBe('assistant')
|
|
239
|
-
// …and the reject frame streams nothing, so the turn leaves the thread
|
|
240
|
-
// exactly as it was — no bare user label, no blank bubble.
|
|
241
|
-
r.endSseTurn()
|
|
242
|
-
expect(r.state.messages).toHaveLength(2)
|
|
243
|
-
expect(r.state.messages.map((m) => m.role)).toEqual(['user', 'assistant'])
|
|
244
|
-
})
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
describe('createChatStreamReducer — participant dedup', () => {
|
|
248
|
-
it('optimistic echo consumes exactly one MESSAGE_REQUEST twin', () => {
|
|
249
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
250
|
-
r.pushOptimisticSend('yes')
|
|
251
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
252
|
-
// Echo of our own send → consumed, no duplicate row.
|
|
253
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'yes' })
|
|
254
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
255
|
-
// A genuinely repeated send from another session still renders.
|
|
256
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'yes', seq: 10 })
|
|
257
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
it('ADMIN echo is NOT consumed by default (it is a technician reply)', () => {
|
|
261
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
262
|
-
r.pushOptimisticSend('same words')
|
|
263
|
-
// An ADMIN-authored row that happens to match our text is somebody
|
|
264
|
-
// else's message on hosts where the operator is not the admin.
|
|
265
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN' })
|
|
266
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
it('ADMIN echo IS consumed when the host declares ownEchoIncludesAdmin', () => {
|
|
270
|
-
const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
|
|
271
|
-
r.pushOptimisticSend('same words')
|
|
272
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN' })
|
|
273
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
274
|
-
// Still one-shot: a second ADMIN row with the same text renders.
|
|
275
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN', seq: 11 })
|
|
276
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
it('participant seen-seq set dedups redelivered rows', () => {
|
|
280
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
281
|
-
r.apply({ type: 'participant', kind: 'direct-message', text: 'human here', seq: 7 })
|
|
282
|
-
const before = r.state.messages
|
|
283
|
-
r.apply({ type: 'participant', kind: 'direct-message', text: 'human here', seq: 7 })
|
|
284
|
-
expect(r.state.messages).toBe(before)
|
|
285
|
-
expect(r.state.messages).toHaveLength(1)
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* REGRESSION (round 3): on a shared ADMIN side, raw-text echo matching
|
|
290
|
-
* could delete a SECOND technician's message. Tech A sends "ok", A's echo
|
|
291
|
-
* never lands; Tech B sends "ok" → ADMIN-authored, matched the stale entry,
|
|
292
|
-
* consumed, never rendered for A.
|
|
293
|
-
*/
|
|
294
|
-
it('selfUserId: another author\'s identical text never consumes our echo', () => {
|
|
295
|
-
const r = createChatStreamReducer({
|
|
296
|
-
transport: 'nats',
|
|
297
|
-
ownEchoIncludesAdmin: true,
|
|
298
|
-
selfUserId: 'tech-a',
|
|
299
|
-
})
|
|
300
|
-
r.pushOptimisticSend('ok')
|
|
301
|
-
// Tech B's message — same text, same ADMIN owner type, different author.
|
|
302
|
-
r.apply({
|
|
303
|
-
type: 'participant',
|
|
304
|
-
kind: 'message-request',
|
|
305
|
-
text: 'ok',
|
|
306
|
-
ownerType: 'ADMIN',
|
|
307
|
-
userId: 'tech-b',
|
|
308
|
-
seq: 1,
|
|
309
|
-
})
|
|
310
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
311
|
-
// Our OWN echo still dedups.
|
|
312
|
-
r.apply({
|
|
313
|
-
type: 'participant',
|
|
314
|
-
kind: 'message-request',
|
|
315
|
-
text: 'ok',
|
|
316
|
-
ownerType: 'ADMIN',
|
|
317
|
-
userId: 'tech-a',
|
|
318
|
-
seq: 2,
|
|
319
|
-
})
|
|
320
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* REGRESSION (round 4): the author guard must fail OPEN. A transport whose
|
|
325
|
-
* decoder does not surface the author id (the ticket wire model declares it
|
|
326
|
-
* at `owner.userId`; the app's id may live in another id space entirely)
|
|
327
|
-
* would otherwise never dedup — every send rendered TWICE, strictly worse
|
|
328
|
-
* than the message-theft bug the guard fixes.
|
|
329
|
-
*/
|
|
330
|
-
it('selfUserId: an id-LESS row still dedups via the text+TTL fallback (fails open)', () => {
|
|
331
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
332
|
-
try {
|
|
333
|
-
const r = createChatStreamReducer({
|
|
334
|
-
transport: 'nats',
|
|
335
|
-
ownEchoIncludesAdmin: true,
|
|
336
|
-
selfUserId: 'tech-a',
|
|
337
|
-
})
|
|
338
|
-
r.pushOptimisticSend('on it')
|
|
339
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'on it', ownerType: 'ADMIN', seq: 1 })
|
|
340
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
341
|
-
// The misconfiguration is surfaced — once, not per row.
|
|
342
|
-
expect(warn).toHaveBeenCalledTimes(1)
|
|
343
|
-
expect(String(warn.mock.calls[0]?.[0])).toContain('selfUserId')
|
|
344
|
-
r.pushOptimisticSend('again')
|
|
345
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'again', ownerType: 'ADMIN', seq: 2 })
|
|
346
|
-
expect(warn).toHaveBeenCalledTimes(1)
|
|
347
|
-
} finally {
|
|
348
|
-
warn.mockRestore()
|
|
349
|
-
}
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
it('selfUserId accepts a GETTER resolved at event time (late auth / user switch)', () => {
|
|
353
|
-
let self: string | undefined
|
|
354
|
-
const r = createChatStreamReducer({
|
|
355
|
-
transport: 'nats',
|
|
356
|
-
ownEchoIncludesAdmin: true,
|
|
357
|
-
selfUserId: () => self,
|
|
358
|
-
})
|
|
359
|
-
// Auth rehydrates AFTER the reducer was created.
|
|
360
|
-
self = 'tech-a'
|
|
361
|
-
r.pushOptimisticSend('ok')
|
|
362
|
-
r.apply({
|
|
363
|
-
type: 'participant',
|
|
364
|
-
kind: 'message-request',
|
|
365
|
-
text: 'ok',
|
|
366
|
-
ownerType: 'ADMIN',
|
|
367
|
-
userId: 'tech-b',
|
|
368
|
-
seq: 1,
|
|
369
|
-
})
|
|
370
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
371
|
-
// Log out, log in as somebody else — the guard tracks it without a reload.
|
|
372
|
-
self = 'tech-b'
|
|
373
|
-
r.pushOptimisticSend('ok')
|
|
374
|
-
r.apply({
|
|
375
|
-
type: 'participant',
|
|
376
|
-
kind: 'message-request',
|
|
377
|
-
text: 'ok',
|
|
378
|
-
ownerType: 'ADMIN',
|
|
379
|
-
userId: 'tech-b',
|
|
380
|
-
seq: 2,
|
|
381
|
-
})
|
|
382
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(3)
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* REGRESSION (round 4): a JetStream catch-up replay after a >30s network gap
|
|
387
|
-
* delivers our own echo long after the send. Its rows carry `seq`, so the
|
|
388
|
-
* seq-less content-dedup fallback cannot rescue it — expiring an
|
|
389
|
-
* AUTHOR-MATCHED entry at the short TTL means a guaranteed duplicate row.
|
|
390
|
-
*/
|
|
391
|
-
it('an AUTHOR-MATCHED echo is consumed well past the unattributed TTL', () => {
|
|
392
|
-
const realNow = Date.now
|
|
393
|
-
try {
|
|
394
|
-
let now = 1_000_000
|
|
395
|
-
Date.now = () => now
|
|
396
|
-
const r = createChatStreamReducer({
|
|
397
|
-
transport: 'nats',
|
|
398
|
-
ownEchoIncludesAdmin: true,
|
|
399
|
-
selfUserId: 'tech-a',
|
|
400
|
-
})
|
|
401
|
-
r.pushOptimisticSend('on it')
|
|
402
|
-
now += OWN_ECHO_TTL_MS + 1_000
|
|
403
|
-
r.apply({
|
|
404
|
-
type: 'participant',
|
|
405
|
-
kind: 'message-request',
|
|
406
|
-
text: 'on it',
|
|
407
|
-
ownerType: 'ADMIN',
|
|
408
|
-
userId: 'tech-a',
|
|
409
|
-
seq: 9,
|
|
410
|
-
})
|
|
411
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
412
|
-
// Still one-shot — a second author-matched row with the same text renders.
|
|
413
|
-
r.apply({
|
|
414
|
-
type: 'participant',
|
|
415
|
-
kind: 'message-request',
|
|
416
|
-
text: 'on it',
|
|
417
|
-
ownerType: 'ADMIN',
|
|
418
|
-
userId: 'tech-a',
|
|
419
|
-
seq: 10,
|
|
420
|
-
})
|
|
421
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
422
|
-
} finally {
|
|
423
|
-
Date.now = realNow
|
|
424
|
-
}
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* REGRESSION (round 5): the author check rules out a COLLEAGUE's message, not
|
|
429
|
-
* the SAME user on a second tab. An entry whose echo never landed used to
|
|
430
|
-
* stay armed forever on the author-matched path, so the same user's identical
|
|
431
|
-
* send from another tab an hour later was silently DROPPED — message loss,
|
|
432
|
-
* strictly worse than the duplicate row the bypass was avoiding.
|
|
433
|
-
*/
|
|
434
|
-
it('an AUTHOR-MATCHED entry expires, so an identical send an HOUR later renders', () => {
|
|
435
|
-
const realNow = Date.now
|
|
436
|
-
try {
|
|
437
|
-
let now = 1_000_000
|
|
438
|
-
Date.now = () => now
|
|
439
|
-
const r = createChatStreamReducer({
|
|
440
|
-
transport: 'nats',
|
|
441
|
-
ownEchoIncludesAdmin: true,
|
|
442
|
-
selfUserId: 'tech-a',
|
|
443
|
-
})
|
|
444
|
-
// Tab A sends "ok" — the echo never lands (dropped frame / reconnect gap).
|
|
445
|
-
r.pushOptimisticSend('ok')
|
|
446
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
447
|
-
// An HOUR later the same user sends "ok" from tab B. The row is
|
|
448
|
-
// author-matched, but the stale entry must NOT eat it.
|
|
449
|
-
now += 60 * 60_000
|
|
450
|
-
r.apply({
|
|
451
|
-
type: 'participant',
|
|
452
|
-
kind: 'message-request',
|
|
453
|
-
text: 'ok',
|
|
454
|
-
ownerType: 'ADMIN',
|
|
455
|
-
userId: 'tech-a',
|
|
456
|
-
seq: 7,
|
|
457
|
-
})
|
|
458
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
459
|
-
} finally {
|
|
460
|
-
Date.now = realNow
|
|
461
|
-
}
|
|
462
|
-
})
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* REGRESSION (round 6): `OWN_ECHO_AUTHOR_TTL_MS` alone preserved TEN MINUTES
|
|
466
|
-
* of cross-tab message loss. A `turn-end` proves the server finished the
|
|
467
|
-
* send, so an echo that has not landed by then never will — the entry must
|
|
468
|
-
* be disarmed at that boundary, cutting the window to one turn.
|
|
469
|
-
*/
|
|
470
|
-
it('a turn-end disarms a stale echo, so a second-tab send minutes later RENDERS', () => {
|
|
471
|
-
const realNow = Date.now
|
|
472
|
-
try {
|
|
473
|
-
let now = 1_000_000
|
|
474
|
-
Date.now = () => now
|
|
475
|
-
const r = createChatStreamReducer({
|
|
476
|
-
transport: 'nats',
|
|
477
|
-
ownEchoIncludesAdmin: true,
|
|
478
|
-
selfUserId: 'tech-a',
|
|
479
|
-
})
|
|
480
|
-
// Tab A sends "ok" — the echo never lands.
|
|
481
|
-
r.pushOptimisticSend('ok')
|
|
482
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
483
|
-
// The turn runs and ends: the server is demonstrably done with that send.
|
|
484
|
-
r.apply({ type: 'turn-start', seq: 2 })
|
|
485
|
-
r.apply({ type: 'turn-end', seq: 3 })
|
|
486
|
-
// Five minutes later — well INSIDE the 10-minute backstop — the same
|
|
487
|
-
// user sends "ok" from tab B. It must render, not be swallowed.
|
|
488
|
-
now += 5 * 60_000
|
|
489
|
-
r.apply({
|
|
490
|
-
type: 'participant',
|
|
491
|
-
kind: 'message-request',
|
|
492
|
-
text: 'ok',
|
|
493
|
-
ownerType: 'ADMIN',
|
|
494
|
-
userId: 'tech-a',
|
|
495
|
-
seq: 7,
|
|
496
|
-
})
|
|
497
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
498
|
-
} finally {
|
|
499
|
-
Date.now = realNow
|
|
500
|
-
}
|
|
501
|
-
})
|
|
502
|
-
|
|
503
|
-
/** The echo entry is still consumed on the NORMAL path: the server's echo
|
|
504
|
-
* arrives before the turn ends, so `turn-end` must not break dedup. */
|
|
505
|
-
it('an echo landing BEFORE turn-end is still deduped', () => {
|
|
506
|
-
const r = createChatStreamReducer({
|
|
507
|
-
transport: 'nats',
|
|
508
|
-
ownEchoIncludesAdmin: true,
|
|
509
|
-
selfUserId: 'tech-a',
|
|
510
|
-
})
|
|
511
|
-
r.pushOptimisticSend('ok')
|
|
512
|
-
r.apply({
|
|
513
|
-
type: 'participant',
|
|
514
|
-
kind: 'message-request',
|
|
515
|
-
text: 'ok',
|
|
516
|
-
ownerType: 'ADMIN',
|
|
517
|
-
userId: 'tech-a',
|
|
518
|
-
seq: 2,
|
|
519
|
-
})
|
|
520
|
-
r.apply({ type: 'turn-start', seq: 3 })
|
|
521
|
-
r.apply({ type: 'turn-end', seq: 4 })
|
|
522
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* REGRESSION (round 7): the round-6 purge was UNCONDITIONAL, so a `turn-end`
|
|
527
|
-
* arriving between a send and its echo disarmed an entry that was never
|
|
528
|
-
* stale — the echo then rendered a SECOND user bubble. Reachable via a
|
|
529
|
-
* reconnect catch-up replay (a pre-disconnect MESSAGE_END delivered after
|
|
530
|
-
* the user has already sent) and via the approval-interrupt path (sending
|
|
531
|
-
* while an approval pends cancels the in-flight turn, so the interrupted
|
|
532
|
-
* turn's MESSAGE_END lands after the new send armed its entry). Only
|
|
533
|
-
* entries armed BEFORE the turn being ended may be purged.
|
|
534
|
-
*/
|
|
535
|
-
it('a turn-end arriving between a send and its echo does NOT duplicate the bubble', () => {
|
|
536
|
-
const r = createChatStreamReducer({
|
|
537
|
-
transport: 'nats',
|
|
538
|
-
ownEchoIncludesAdmin: true,
|
|
539
|
-
selfUserId: 'me',
|
|
540
|
-
})
|
|
541
|
-
r.pushOptimisticSend('hello world')
|
|
542
|
-
// A stale MESSAGE_END for a turn that started before this send (catch-up
|
|
543
|
-
// replay / approval interrupt) — no turn-start was ever observed here.
|
|
544
|
-
r.apply({ type: 'turn-end', seq: 10 })
|
|
545
|
-
r.apply({
|
|
546
|
-
type: 'participant',
|
|
547
|
-
kind: 'message-request',
|
|
548
|
-
text: 'hello world',
|
|
549
|
-
ownerType: 'ADMIN',
|
|
550
|
-
userId: 'me',
|
|
551
|
-
seq: 11,
|
|
552
|
-
})
|
|
553
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
554
|
-
})
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* DOCUMENTED LIMIT, not an aspiration: when a reconnect catch-up replay
|
|
558
|
-
* delivers BOTH a pre-disconnect `turn-start` and its `turn-end` after the
|
|
559
|
-
* user has sent, the replayed `turn-start` restamps the arrival-time
|
|
560
|
-
* boundary and the `turn-end` purges the still-live entry — the echo then
|
|
561
|
-
* renders a SECOND bubble. The normalized event union carries no event-own
|
|
562
|
-
* timestamp (only `seq`), so at this layer the interleaving is identical to
|
|
563
|
-
* "send queued mid-turn, own turn opened and closed before the echo", which
|
|
564
|
-
* the module deliberately resolves toward duplicating rather than risk
|
|
565
|
-
* swallowing a real message. Pins the behavior so a future transport-level
|
|
566
|
-
* fix (event-own time) has to update this test consciously.
|
|
567
|
-
*/
|
|
568
|
-
it('a replayed turn boundary after a send duplicates the bubble (known limit)', () => {
|
|
569
|
-
const realNow = Date.now
|
|
570
|
-
try {
|
|
571
|
-
let now = 1_000_000
|
|
572
|
-
Date.now = () => now
|
|
573
|
-
const r = createChatStreamReducer({
|
|
574
|
-
transport: 'nats',
|
|
575
|
-
ownEchoIncludesAdmin: true,
|
|
576
|
-
selfUserId: 'me',
|
|
577
|
-
})
|
|
578
|
-
r.pushOptimisticSend('hello')
|
|
579
|
-
now += 5 // the replay lands a few ms after the send
|
|
580
|
-
r.apply({ type: 'turn-start', seq: 1 })
|
|
581
|
-
r.apply({ type: 'turn-end', seq: 2 })
|
|
582
|
-
r.apply({
|
|
583
|
-
type: 'participant',
|
|
584
|
-
kind: 'message-request',
|
|
585
|
-
text: 'hello',
|
|
586
|
-
ownerType: 'ADMIN',
|
|
587
|
-
userId: 'me',
|
|
588
|
-
seq: 3,
|
|
589
|
-
})
|
|
590
|
-
// Two bubbles: the optimistic one and the un-deduped echo.
|
|
591
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
592
|
-
} finally {
|
|
593
|
-
Date.now = realNow
|
|
594
|
-
}
|
|
595
|
-
})
|
|
596
|
-
|
|
597
|
-
/** Same shape, but the interrupted turn's `turn-start` WAS observed and the
|
|
598
|
-
* send happened after it — the entry belongs to the current turn and must
|
|
599
|
-
* survive its `turn-end`. */
|
|
600
|
-
it('an echo armed DURING the turn survives that turn-end', () => {
|
|
601
|
-
const realNow = Date.now
|
|
602
|
-
try {
|
|
603
|
-
let now = 1_000_000
|
|
604
|
-
Date.now = () => now
|
|
605
|
-
const r = createChatStreamReducer({
|
|
606
|
-
transport: 'nats',
|
|
607
|
-
ownEchoIncludesAdmin: true,
|
|
608
|
-
selfUserId: 'tech-a',
|
|
609
|
-
})
|
|
610
|
-
r.apply({ type: 'turn-start', seq: 2 })
|
|
611
|
-
now += 1 // the user types and sends while the turn streams
|
|
612
|
-
r.pushOptimisticSend('hello world')
|
|
613
|
-
r.apply({ type: 'turn-end', seq: 3 })
|
|
614
|
-
r.apply({
|
|
615
|
-
type: 'participant',
|
|
616
|
-
kind: 'message-request',
|
|
617
|
-
text: 'hello world',
|
|
618
|
-
ownerType: 'ADMIN',
|
|
619
|
-
userId: 'tech-a',
|
|
620
|
-
seq: 4,
|
|
621
|
-
})
|
|
622
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
623
|
-
} finally {
|
|
624
|
-
Date.now = realNow
|
|
625
|
-
}
|
|
626
|
-
})
|
|
627
|
-
|
|
628
|
-
it('an author-matched entry is still consumed just INSIDE the author TTL', () => {
|
|
629
|
-
const realNow = Date.now
|
|
630
|
-
try {
|
|
631
|
-
let now = 1_000_000
|
|
632
|
-
Date.now = () => now
|
|
633
|
-
const r = createChatStreamReducer({
|
|
634
|
-
transport: 'nats',
|
|
635
|
-
ownEchoIncludesAdmin: true,
|
|
636
|
-
selfUserId: 'tech-a',
|
|
637
|
-
})
|
|
638
|
-
r.pushOptimisticSend('ok')
|
|
639
|
-
now += OWN_ECHO_AUTHOR_TTL_MS - 1
|
|
640
|
-
r.apply({
|
|
641
|
-
type: 'participant',
|
|
642
|
-
kind: 'message-request',
|
|
643
|
-
text: 'ok',
|
|
644
|
-
ownerType: 'ADMIN',
|
|
645
|
-
userId: 'tech-a',
|
|
646
|
-
seq: 7,
|
|
647
|
-
})
|
|
648
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
649
|
-
} finally {
|
|
650
|
-
Date.now = realNow
|
|
651
|
-
}
|
|
652
|
-
})
|
|
653
|
-
|
|
654
|
-
/** A short-TTL (unattributed) lookup must not EVICT an entry a later
|
|
655
|
-
* author-matched row is still entitled to consume. */
|
|
656
|
-
it('an unattributed miss does not evict an entry the author path still owns', () => {
|
|
657
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
658
|
-
const realNow = Date.now
|
|
659
|
-
try {
|
|
660
|
-
let now = 1_000_000
|
|
661
|
-
Date.now = () => now
|
|
662
|
-
const r = createChatStreamReducer({
|
|
663
|
-
transport: 'nats',
|
|
664
|
-
ownEchoIncludesAdmin: true,
|
|
665
|
-
selfUserId: 'tech-a',
|
|
666
|
-
})
|
|
667
|
-
r.pushOptimisticSend('ok')
|
|
668
|
-
now += OWN_ECHO_TTL_MS + 1
|
|
669
|
-
// Unattributed row: outside the short TTL, so it renders (2 user rows)…
|
|
670
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'ok', ownerType: 'ADMIN', seq: 5 })
|
|
671
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
672
|
-
// …and our own late echo, declared ours, is still consumed.
|
|
673
|
-
r.apply({
|
|
674
|
-
type: 'participant',
|
|
675
|
-
kind: 'message-request',
|
|
676
|
-
text: 'ok',
|
|
677
|
-
ownerType: 'ADMIN',
|
|
678
|
-
userId: 'tech-a',
|
|
679
|
-
seq: 6,
|
|
680
|
-
})
|
|
681
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
682
|
-
} finally {
|
|
683
|
-
Date.now = realNow
|
|
684
|
-
warn.mockRestore()
|
|
685
|
-
}
|
|
686
|
-
})
|
|
687
|
-
|
|
688
|
-
it('an aged entry still expires on the UNATTRIBUTED path (no author to trust)', () => {
|
|
689
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
690
|
-
const realNow = Date.now
|
|
691
|
-
try {
|
|
692
|
-
let now = 1_000_000
|
|
693
|
-
Date.now = () => now
|
|
694
|
-
const r = createChatStreamReducer({
|
|
695
|
-
transport: 'nats',
|
|
696
|
-
ownEchoIncludesAdmin: true,
|
|
697
|
-
selfUserId: 'tech-a',
|
|
698
|
-
})
|
|
699
|
-
r.pushOptimisticSend('done')
|
|
700
|
-
now += OWN_ECHO_TTL_MS + 1
|
|
701
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
|
|
702
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
703
|
-
} finally {
|
|
704
|
-
Date.now = realNow
|
|
705
|
-
warn.mockRestore()
|
|
706
|
-
}
|
|
707
|
-
})
|
|
708
|
-
|
|
709
|
-
it('an echo entry expires, so an un-echoed send cannot arm a trap', () => {
|
|
710
|
-
const realNow = Date.now
|
|
711
|
-
try {
|
|
712
|
-
let now = 1_000_000
|
|
713
|
-
Date.now = () => now
|
|
714
|
-
const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
|
|
715
|
-
r.pushOptimisticSend('done')
|
|
716
|
-
// Our echo never lands. Much later, somebody else says the same thing.
|
|
717
|
-
now += OWN_ECHO_TTL_MS + 1
|
|
718
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
|
|
719
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
|
|
720
|
-
} finally {
|
|
721
|
-
Date.now = realNow
|
|
722
|
-
}
|
|
723
|
-
})
|
|
724
|
-
|
|
725
|
-
it('an echo INSIDE the TTL is still consumed', () => {
|
|
726
|
-
const realNow = Date.now
|
|
727
|
-
try {
|
|
728
|
-
let now = 1_000_000
|
|
729
|
-
Date.now = () => now
|
|
730
|
-
const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
|
|
731
|
-
r.pushOptimisticSend('done')
|
|
732
|
-
now += OWN_ECHO_TTL_MS - 1
|
|
733
|
-
r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
|
|
734
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
735
|
-
} finally {
|
|
736
|
-
Date.now = realNow
|
|
737
|
-
}
|
|
738
|
-
})
|
|
739
|
-
})
|
|
740
|
-
|
|
741
|
-
/**
|
|
742
|
-
* Parked-state round trip across an LRU eviction. The store captures these
|
|
743
|
-
* (`EvictedReducerState`); these tests pin the reducer half of the contract.
|
|
744
|
-
*/
|
|
745
|
-
describe('createChatStreamReducer — parked echo restore', () => {
|
|
746
|
-
const echoOptions = {
|
|
747
|
-
transport: 'nats',
|
|
748
|
-
ownEchoIncludesAdmin: true,
|
|
749
|
-
selfUserId: 'me',
|
|
750
|
-
} as const
|
|
751
|
-
|
|
752
|
-
it('restored pendingEchoes still consume the echo (no duplicate bubble)', () => {
|
|
753
|
-
const dropped = createChatStreamReducer(echoOptions)
|
|
754
|
-
dropped.pushOptimisticSend('hello world')
|
|
755
|
-
const parked = dropped.getPendingEchoes()
|
|
756
|
-
expect(parked).toHaveLength(1)
|
|
757
|
-
|
|
758
|
-
// The key was evicted between the send and its echo; the replacement
|
|
759
|
-
// reducer restores the armed entry alongside the refetched thread.
|
|
760
|
-
const recreated = createChatStreamReducer(echoOptions)
|
|
761
|
-
recreated.initializeWithState(
|
|
762
|
-
[{ id: 'u1', role: 'user', content: 'hello world' }],
|
|
763
|
-
{ pendingEchoes: parked },
|
|
764
|
-
)
|
|
765
|
-
recreated.apply({
|
|
766
|
-
type: 'participant',
|
|
767
|
-
kind: 'message-request',
|
|
768
|
-
text: 'hello world',
|
|
769
|
-
ownerType: 'ADMIN',
|
|
770
|
-
userId: 'me',
|
|
771
|
-
seq: 11,
|
|
772
|
-
})
|
|
773
|
-
expect(recreated.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
774
|
-
})
|
|
775
|
-
|
|
776
|
-
it('getPendingEchoes returns a SNAPSHOT, not the live internal array', () => {
|
|
777
|
-
const r = createChatStreamReducer(echoOptions)
|
|
778
|
-
r.pushOptimisticSend('hello world')
|
|
779
|
-
const parked = r.getPendingEchoes()
|
|
780
|
-
expect(parked).toHaveLength(1)
|
|
781
|
-
|
|
782
|
-
// Mutating the reducer after the snapshot must not change what the holder
|
|
783
|
-
// observes — the `readonly PendingEcho[]` return type has to be honest.
|
|
784
|
-
r.pushOptimisticSend('second')
|
|
785
|
-
expect(parked).toHaveLength(1)
|
|
786
|
-
expect(r.getPendingEchoes()).toHaveLength(2)
|
|
787
|
-
|
|
788
|
-
// ...and the consume path (which splices) must not empty it either.
|
|
789
|
-
r.apply({
|
|
790
|
-
type: 'participant',
|
|
791
|
-
kind: 'message-request',
|
|
792
|
-
text: 'hello world',
|
|
793
|
-
ownerType: 'ADMIN',
|
|
794
|
-
userId: 'me',
|
|
795
|
-
seq: 11,
|
|
796
|
-
})
|
|
797
|
-
expect(parked).toHaveLength(1)
|
|
798
|
-
expect(parked[0]?.text).toBe('hello world')
|
|
799
|
-
})
|
|
800
|
-
|
|
801
|
-
it('drops parked entries already past the author TTL', () => {
|
|
802
|
-
const r = createChatStreamReducer(echoOptions)
|
|
803
|
-
r.initializeWithState(null, {
|
|
804
|
-
pendingEchoes: [{ text: 'ok', at: Date.now() - OWN_ECHO_AUTHOR_TTL_MS - 1 }],
|
|
805
|
-
})
|
|
806
|
-
expect(r.getPendingEchoes()).toHaveLength(0)
|
|
807
|
-
r.apply({
|
|
808
|
-
type: 'participant',
|
|
809
|
-
kind: 'message-request',
|
|
810
|
-
text: 'ok',
|
|
811
|
-
ownerType: 'ADMIN',
|
|
812
|
-
userId: 'me',
|
|
813
|
-
seq: 3,
|
|
814
|
-
})
|
|
815
|
-
expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
816
|
-
})
|
|
817
|
-
})
|
|
818
|
-
|
|
819
|
-
describe('createChatStreamReducer — initializeWithState resumed gate', () => {
|
|
820
|
-
/** An eviction restore of a key that NEVER streamed (empty parked thread)
|
|
821
|
-
* must not claim "resumed": a later cold delta with no `turn-start` would
|
|
822
|
-
* take the append branch and never spawn the first assistant bubble. */
|
|
823
|
-
it('an EMPTY restore leaves the cold-start cumulative path armed', () => {
|
|
824
|
-
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
825
|
-
const r = createChatStreamReducer({
|
|
826
|
-
transport: 'nats',
|
|
827
|
-
onEffect: (e) => effects.push(e),
|
|
828
|
-
})
|
|
829
|
-
r.initializeWithState([], { lastAppliedSeq: 1 })
|
|
830
|
-
r.apply({ type: 'text-delta', text: 'hello', seq: 2 })
|
|
831
|
-
const update = effects.filter((e) => e.name === 'onSegmentsUpdate').at(-1)
|
|
832
|
-
expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBeUndefined()
|
|
833
|
-
expect(r.state.messages.filter((m) => m.role === 'assistant')).toHaveLength(1)
|
|
834
|
-
})
|
|
835
|
-
|
|
836
|
-
it('a NON-EMPTY restore is treated as resumed (post-stream deltas append)', () => {
|
|
837
|
-
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
838
|
-
const r = createChatStreamReducer({
|
|
839
|
-
transport: 'nats',
|
|
840
|
-
onEffect: (e) => effects.push(e),
|
|
841
|
-
})
|
|
842
|
-
r.initializeWithState([{ id: 'a1', role: 'assistant', content: 'prior', segments: [] }])
|
|
843
|
-
r.apply({ type: 'text-delta', text: 'more', seq: 2 })
|
|
844
|
-
const update = effects.filter((e) => e.name === 'onSegmentsUpdate').at(-1)
|
|
845
|
-
expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBe(true)
|
|
846
|
-
})
|
|
847
|
-
|
|
848
|
-
it('an explicit `resumed` overrides the derivation', () => {
|
|
849
|
-
const effects: Array<{ name: string; args: unknown[] }> = []
|
|
850
|
-
const r = createChatStreamReducer({
|
|
851
|
-
transport: 'nats',
|
|
852
|
-
onEffect: (e) => effects.push(e),
|
|
853
|
-
})
|
|
854
|
-
r.initializeWithState([], { resumed: true })
|
|
855
|
-
r.apply({ type: 'text-delta', text: 'hello', seq: 2 })
|
|
856
|
-
const update = effects.filter((e) => e.name === 'onSegmentsUpdate').at(-1)
|
|
857
|
-
expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBe(true)
|
|
858
|
-
})
|
|
859
|
-
})
|
|
860
|
-
|
|
861
|
-
describe('createChatStreamReducer — mergeApprovalStatuses precedence', () => {
|
|
862
|
-
it('stream-learned resolution beats a LAGGING persisted pending', () => {
|
|
863
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
864
|
-
r.setApprovalStatus('req-1', 'approved')
|
|
865
|
-
r.mergeApprovalStatuses({ 'req-1': 'pending' })
|
|
866
|
-
expect(r.state.approvalStatuses['req-1']).toBe('approved')
|
|
867
|
-
})
|
|
868
|
-
|
|
869
|
-
it('persisted resolution beats a STALE stream-learned pending (second tab)', () => {
|
|
870
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
871
|
-
// This tab saw the request; the operator approved it in another tab.
|
|
872
|
-
r.apply({
|
|
873
|
-
type: 'approval-request',
|
|
874
|
-
requestId: 'req-2',
|
|
875
|
-
command: 'rm -rf /tmp/x',
|
|
876
|
-
approvalType: 'CLIENT',
|
|
877
|
-
seq: 1,
|
|
878
|
-
})
|
|
879
|
-
expect(r.state.approvalStatuses['req-2']).toBeUndefined()
|
|
880
|
-
r.setApprovalStatus('req-2', 'pending')
|
|
881
|
-
r.mergeApprovalStatuses({ 'req-2': 'approved' })
|
|
882
|
-
expect(r.state.approvalStatuses['req-2']).toBe('approved')
|
|
883
|
-
})
|
|
884
|
-
|
|
885
|
-
it('fills unknown ids and is a no-op when nothing changes', () => {
|
|
886
|
-
const r = createChatStreamReducer({ transport: 'nats' })
|
|
887
|
-
r.setApprovalStatus('req-3', 'rejected')
|
|
888
|
-
r.mergeApprovalStatuses({ 'req-3': 'rejected', 'req-4': 'cancelled' })
|
|
889
|
-
expect(r.state.approvalStatuses).toEqual({ 'req-3': 'rejected', 'req-4': 'cancelled' })
|
|
890
|
-
const before = r.state.approvalStatuses
|
|
891
|
-
r.mergeApprovalStatuses({ 'req-3': 'pending', 'req-4': 'cancelled' })
|
|
892
|
-
expect(r.state.approvalStatuses).toBe(before)
|
|
893
|
-
})
|
|
894
|
-
})
|