@flamingo-stack/openframe-frontend-core 0.0.579-1952.5335.1 → 0.0.579
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-protocol/decode.d.ts.map +1 -1
- package/dist/chat-protocol/events.d.ts +47 -1
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +51 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +48 -1
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/leading-frames.d.ts +9 -3
- package/dist/chat-protocol/leading-frames.d.ts.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts +41 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-3B42TFEE.cjs → chunk-36TJYCF3.cjs} +27 -27
- package/dist/{chunk-3B42TFEE.cjs.map → chunk-36TJYCF3.cjs.map} +1 -1
- package/dist/{chunk-YYEVFC3K.cjs → chunk-37XJYOAY.cjs} +17 -17
- package/dist/{chunk-YYEVFC3K.cjs.map → chunk-37XJYOAY.cjs.map} +1 -1
- package/dist/{chunk-DBP7S75Q.js → chunk-3AX3ZEY3.js} +1481 -1318
- package/dist/chunk-3AX3ZEY3.js.map +1 -0
- package/dist/{chunk-DGB2FLFH.cjs → chunk-4FJSN2XW.cjs} +10 -10
- package/dist/{chunk-DGB2FLFH.cjs.map → chunk-4FJSN2XW.cjs.map} +1 -1
- package/dist/{chunk-WHSSI3S7.cjs → chunk-4HGRXFQP.cjs} +3 -3
- package/dist/{chunk-WHSSI3S7.cjs.map → chunk-4HGRXFQP.cjs.map} +1 -1
- package/dist/{chunk-SY5HCZZO.cjs → chunk-4LZ7B5H5.cjs} +5 -5
- package/dist/{chunk-SY5HCZZO.cjs.map → chunk-4LZ7B5H5.cjs.map} +1 -1
- package/dist/{chunk-XJ45MHWI.js → chunk-4WSKSNXL.js} +9 -9
- package/dist/{chunk-ZCEQLHU3.js → chunk-5Y6TPFW7.js} +5 -5
- package/dist/{chunk-EVTRRDIH.cjs → chunk-A6C5HOD7.cjs} +23 -23
- package/dist/{chunk-EVTRRDIH.cjs.map → chunk-A6C5HOD7.cjs.map} +1 -1
- package/dist/{chunk-RXZWUJEB.js → chunk-AKPLEWMH.js} +2 -2
- package/dist/{chunk-7WKFLOC7.cjs → chunk-BCJQ7CJE.cjs} +11 -11
- package/dist/{chunk-7WKFLOC7.cjs.map → chunk-BCJQ7CJE.cjs.map} +1 -1
- package/dist/{chunk-C4KL7OQI.js → chunk-BW4WDUSI.js} +3 -3
- package/dist/{chunk-L66XBQM6.js → chunk-CP5EYMNL.js} +276 -160
- package/dist/chunk-CP5EYMNL.js.map +1 -0
- package/dist/{chunk-QOPQ6BCA.js → chunk-D44S2QLI.js} +3 -3
- package/dist/{chunk-CGVPUA4X.js → chunk-DB3TV4CW.js} +6 -6
- package/dist/{chunk-PITZMTGE.cjs → chunk-FEVLXRTC.cjs} +506 -390
- package/dist/chunk-FEVLXRTC.cjs.map +1 -0
- package/dist/{chunk-TYJITV3H.js → chunk-H4BPHGU5.js} +3 -3
- package/dist/{chunk-C7Q2PVHD.cjs → chunk-H54EPP3P.cjs} +7 -7
- package/dist/{chunk-C7Q2PVHD.cjs.map → chunk-H54EPP3P.cjs.map} +1 -1
- package/dist/{chunk-NWAUHBYH.js → chunk-HGA74X35.js} +4 -4
- package/dist/{chunk-4C3MS2G6.cjs → chunk-HHJVIHVR.cjs} +81 -81
- package/dist/{chunk-4C3MS2G6.cjs.map → chunk-HHJVIHVR.cjs.map} +1 -1
- package/dist/{chunk-3XFMM34I.cjs → chunk-IFYQ7DHV.cjs} +48 -48
- package/dist/{chunk-3XFMM34I.cjs.map → chunk-IFYQ7DHV.cjs.map} +1 -1
- package/dist/{chunk-QYQXAXVR.cjs → chunk-IKDC3R7V.cjs} +4 -3
- package/dist/chunk-IKDC3R7V.cjs.map +1 -0
- package/dist/{chunk-W6JA6SFD.js → chunk-JRYS4VMV.js} +45 -45
- package/dist/chunk-JRYS4VMV.js.map +1 -0
- package/dist/{chunk-AT4WAFJO.js → chunk-L2CF2QZW.js} +7 -7
- package/dist/{chunk-2BFZHTSD.cjs → chunk-LEISUAHF.cjs} +18 -8
- package/dist/chunk-LEISUAHF.cjs.map +1 -0
- package/dist/{chunk-AAHKPPGQ.cjs → chunk-M5GWCMYE.cjs} +42 -42
- package/dist/{chunk-AAHKPPGQ.cjs.map → chunk-M5GWCMYE.cjs.map} +1 -1
- package/dist/{chunk-PQETCTJ4.js → chunk-MXOSTJAG.js} +4 -3
- package/dist/chunk-MXOSTJAG.js.map +1 -0
- package/dist/{chunk-653A7FPP.cjs → chunk-NPVDKOGD.cjs} +22 -22
- package/dist/{chunk-653A7FPP.cjs.map → chunk-NPVDKOGD.cjs.map} +1 -1
- package/dist/{chunk-2ML5QEDH.js → chunk-O2FTVZOD.js} +5 -5
- package/dist/{chunk-OCSAB6NT.cjs → chunk-OGZAFM6M.cjs} +5 -5
- package/dist/{chunk-OCSAB6NT.cjs.map → chunk-OGZAFM6M.cjs.map} +1 -1
- package/dist/{chunk-CSBNQJSI.js → chunk-PMENIZXI.js} +6 -6
- package/dist/{chunk-PFTEG6L7.js → chunk-PWOYEUIQ.js} +7 -7
- package/dist/{chunk-KXLJT5U5.js → chunk-QCP2KV7P.js} +5 -5
- package/dist/{chunk-6QDW4CDG.js → chunk-QGJYWLF4.js} +2 -2
- package/dist/{chunk-VLRXLTR4.cjs → chunk-RKGOVTXZ.cjs} +377 -214
- package/dist/chunk-RKGOVTXZ.cjs.map +1 -0
- package/dist/{chunk-HM2R5MDR.js → chunk-T5HDPHN6.js} +13 -3
- package/dist/chunk-T5HDPHN6.js.map +1 -0
- package/dist/{chunk-GP2QLZIN.cjs → chunk-YW4WBNYV.cjs} +37 -37
- package/dist/{chunk-GP2QLZIN.cjs.map → chunk-YW4WBNYV.cjs.map} +1 -1
- package/dist/{chunk-36NFJULV.cjs → chunk-ZI27ALYX.cjs} +41 -41
- package/dist/chunk-ZI27ALYX.cjs.map +1 -0
- package/dist/components/case-studies/index.cjs +12 -12
- package/dist/components/case-studies/index.js +6 -6
- package/dist/components/chat/chat-message-enhanced.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/index.cjs +11 -3
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +10 -2
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +9 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/stream/delta-batcher.d.ts +3 -3
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -1
- package/dist/components/chat/stream/message-mutations.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 +30 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +3 -1
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +4 -1
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/approval-display.d.ts +36 -8
- package/dist/components/chat/utils/approval-display.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +1 -1
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +16 -4
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +1 -1
- package/dist/components/contact/index.cjs +7 -7
- package/dist/components/contact/index.js +6 -6
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/docs/index.js +9 -9
- package/dist/components/embeds/index.cjs +7 -7
- package/dist/components/embeds/index.js +6 -6
- package/dist/components/faq/index.cjs +7 -7
- package/dist/components/faq/index.js +6 -6
- package/dist/components/features/board/board.d.ts.map +1 -1
- package/dist/components/features/index.cjs +8 -6
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +7 -5
- package/dist/components/features/sortable-list/index.d.ts +2 -1
- package/dist/components/features/sortable-list/index.d.ts.map +1 -1
- package/dist/components/features/sortable-list/sortable-list.d.ts +8 -1
- package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -1
- package/dist/components/features/sortable-list/sortable-move-buttons.d.ts +24 -0
- package/dist/components/features/sortable-list/sortable-move-buttons.d.ts.map +1 -0
- package/dist/components/features/sortable-list/use-sortable-item.d.ts +12 -4
- package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -1
- package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts +21 -5
- package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +26 -26
- package/dist/components/help-center-pages/index.js +17 -17
- package/dist/components/index.cjs +139 -129
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +26 -16
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +37 -37
- package/dist/components/meeting-scheduler/index.js +5 -5
- package/dist/components/navigation/index.cjs +9 -9
- package/dist/components/navigation/index.js +8 -8
- package/dist/components/onboarding-guides/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.js +8 -8
- package/dist/components/tickets/index.cjs +10 -10
- package/dist/components/tickets/index.js +9 -9
- package/dist/components/ui/file-manager/index.cjs +4 -4
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +14 -6
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +13 -5
- package/dist/components/ui/input.d.ts +2 -0
- package/dist/components/ui/input.d.ts.map +1 -1
- package/dist/components/ui/textarea.d.ts +2 -0
- package/dist/components/ui/textarea.d.ts.map +1 -1
- package/dist/components/ui/ticket-status-config-row.d.ts +10 -4
- package/dist/components/ui/ticket-status-config-row.d.ts.map +1 -1
- package/dist/hooks/index.cjs +7 -3
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +6 -2
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts +31 -0
- package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts.map +1 -0
- package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
- package/dist/index.cjs +25 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +23 -9
- package/dist/index.js.map +1 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -4
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +80 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +118 -1
- package/src/chat-protocol/decode.ts +4 -3
- package/src/chat-protocol/events.ts +51 -0
- package/src/chat-protocol/leading-frames.ts +9 -3
- package/src/chat-protocol/nats-decoder.ts +110 -1
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +19 -0
- package/src/components/chat/chat-message-enhanced.tsx +13 -1
- package/src/components/chat/guide-display.tsx +42 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +90 -0
- package/src/components/chat/stream/chat-dialog-store.ts +1 -1
- package/src/components/chat/stream/chat-stream-reducer.ts +49 -11
- package/src/components/chat/stream/delta-batcher.ts +9 -4
- package/src/components/chat/stream/message-mutations.ts +2 -0
- package/src/components/chat/types/component.types.ts +9 -0
- package/src/components/chat/types/message.types.ts +32 -0
- package/src/components/chat/types/network.types.ts +3 -1
- package/src/components/chat/types/processing.types.ts +4 -1
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +93 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +78 -1
- package/src/components/chat/utils/approval-display.ts +53 -8
- package/src/components/chat/utils/extract-incomplete-message-state.ts +1 -0
- package/src/components/chat/utils/index.ts +6 -1
- package/src/components/chat/utils/message-segment-accumulator.ts +37 -3
- package/src/components/chat/utils/process-historical-messages.ts +44 -7
- package/src/components/chat/utils/resolve-fetched-card-href.ts +1 -1
- package/src/components/features/board/.board.md +1 -0
- package/src/components/features/board/board.tsx +11 -1
- package/src/components/features/sortable-list/.sortable-list.md +23 -12
- package/src/components/features/sortable-list/index.ts +2 -1
- package/src/components/features/sortable-list/sortable-list.tsx +16 -5
- package/src/components/features/sortable-list/sortable-move-buttons.tsx +81 -0
- package/src/components/features/sortable-list/use-sortable-item.ts +25 -13
- package/src/components/features/ticket-status-config-list/ticket-status-config-list.tsx +55 -62
- package/src/components/ui/.ticket-status-config-row.md +11 -9
- package/src/components/ui/input.tsx +5 -2
- package/src/components/ui/textarea.tsx +5 -2
- package/src/components/ui/ticket-status-config-row.tsx +162 -70
- package/src/hooks/ui/.index.md +1 -0
- package/src/hooks/ui/.use-drag-and-drop-enabled.md +32 -0
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-drag-and-drop-enabled.ts +37 -0
- package/src/hooks/ui/use-media-query.ts +3 -2
- package/src/stories/ChatMessageEnhanced.stories.tsx +7 -3
- package/dist/chunk-2BFZHTSD.cjs.map +0 -1
- package/dist/chunk-36NFJULV.cjs.map +0 -1
- package/dist/chunk-DBP7S75Q.js.map +0 -1
- package/dist/chunk-HM2R5MDR.js.map +0 -1
- package/dist/chunk-L66XBQM6.js.map +0 -1
- package/dist/chunk-PITZMTGE.cjs.map +0 -1
- package/dist/chunk-PQETCTJ4.js.map +0 -1
- package/dist/chunk-QYQXAXVR.cjs.map +0 -1
- package/dist/chunk-VLRXLTR4.cjs.map +0 -1
- package/dist/chunk-W6JA6SFD.js.map +0 -1
- /package/dist/{chunk-XJ45MHWI.js.map → chunk-4WSKSNXL.js.map} +0 -0
- /package/dist/{chunk-ZCEQLHU3.js.map → chunk-5Y6TPFW7.js.map} +0 -0
- /package/dist/{chunk-RXZWUJEB.js.map → chunk-AKPLEWMH.js.map} +0 -0
- /package/dist/{chunk-C4KL7OQI.js.map → chunk-BW4WDUSI.js.map} +0 -0
- /package/dist/{chunk-QOPQ6BCA.js.map → chunk-D44S2QLI.js.map} +0 -0
- /package/dist/{chunk-CGVPUA4X.js.map → chunk-DB3TV4CW.js.map} +0 -0
- /package/dist/{chunk-TYJITV3H.js.map → chunk-H4BPHGU5.js.map} +0 -0
- /package/dist/{chunk-NWAUHBYH.js.map → chunk-HGA74X35.js.map} +0 -0
- /package/dist/{chunk-AT4WAFJO.js.map → chunk-L2CF2QZW.js.map} +0 -0
- /package/dist/{chunk-2ML5QEDH.js.map → chunk-O2FTVZOD.js.map} +0 -0
- /package/dist/{chunk-CSBNQJSI.js.map → chunk-PMENIZXI.js.map} +0 -0
- /package/dist/{chunk-PFTEG6L7.js.map → chunk-PWOYEUIQ.js.map} +0 -0
- /package/dist/{chunk-KXLJT5U5.js.map → chunk-QCP2KV7P.js.map} +0 -0
- /package/dist/{chunk-6QDW4CDG.js.map → chunk-QGJYWLF4.js.map} +0 -0
|
@@ -886,6 +886,96 @@ describe('createChatStreamReducer — mergeApprovalStatuses precedence', () => {
|
|
|
886
886
|
});
|
|
887
887
|
});
|
|
888
888
|
|
|
889
|
+
// ─── GUIDE segments (#1583 threaded through the unified reader) ─────────────
|
|
890
|
+
//
|
|
891
|
+
// A `GUIDE` chunk is an APPEND-ONLY body stream like TEXT/THINKING, but it
|
|
892
|
+
// renders as a titled "OpenFrame Guide" card. It must therefore coalesce the
|
|
893
|
+
// same way (extend the trailing `guide` segment, never push a new one per
|
|
894
|
+
// chunk) and satisfy the same reducer invariants — seq idempotency,
|
|
895
|
+
// referential stability, and post-MESSAGE_END append mode.
|
|
896
|
+
|
|
897
|
+
describe('createChatStreamReducer — guide segments', () => {
|
|
898
|
+
it('coalesces consecutive guide deltas into ONE trailing guide segment', () => {
|
|
899
|
+
const r = createChatStreamReducer({ transport: 'nats' });
|
|
900
|
+
r.apply({ type: 'turn-start', seq: 1 });
|
|
901
|
+
r.apply({ type: 'guide-delta', text: '## Enroll a device\n', seq: 2 });
|
|
902
|
+
r.apply({ type: 'guide-delta', text: '1. Open Settings', seq: 3 });
|
|
903
|
+
const last = r.state.messages[r.state.messages.length - 1];
|
|
904
|
+
expect(last.segments).toEqual([{ type: 'guide', text: '## Enroll a device\n1. Open Settings' }]);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('starts a NEW guide segment when a non-guide segment interrupts', () => {
|
|
908
|
+
const r = createChatStreamReducer({ transport: 'nats' });
|
|
909
|
+
r.apply({ type: 'turn-start', seq: 1 });
|
|
910
|
+
r.apply({ type: 'guide-delta', text: 'step one', seq: 2 });
|
|
911
|
+
r.apply({ type: 'text-delta', text: 'aside', seq: 3 });
|
|
912
|
+
r.apply({ type: 'guide-delta', text: 'step two', seq: 4 });
|
|
913
|
+
const last = r.state.messages[r.state.messages.length - 1];
|
|
914
|
+
expect(last.segments).toEqual([
|
|
915
|
+
{ type: 'guide', text: 'step one' },
|
|
916
|
+
{ type: 'text', text: 'aside' },
|
|
917
|
+
{ type: 'guide', text: 'step two' },
|
|
918
|
+
]);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('drops a redelivered guide delta via the shared seq gate (no double text)', () => {
|
|
922
|
+
const r = createChatStreamReducer({ transport: 'nats' });
|
|
923
|
+
r.apply({ type: 'turn-start', seq: 1 });
|
|
924
|
+
r.apply({ type: 'guide-delta', text: 'a', seq: 2 });
|
|
925
|
+
r.apply({ type: 'guide-delta', text: 'b', seq: 3 });
|
|
926
|
+
r.apply({ type: 'guide-delta', text: 'b', seq: 3 });
|
|
927
|
+
r.apply({ type: 'guide-delta', text: 'a', seq: 2 });
|
|
928
|
+
const last = r.state.messages[r.state.messages.length - 1];
|
|
929
|
+
expect(last.segments).toEqual([{ type: 'guide', text: 'ab' }]);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('reclones ONLY the touched message — earlier rows keep identity', () => {
|
|
933
|
+
const r = createChatStreamReducer({ transport: 'nats' });
|
|
934
|
+
r.apply({ type: 'participant', kind: 'message-request', text: 'how do I enroll?', seq: 1 });
|
|
935
|
+
r.apply({ type: 'turn-start', seq: 2 });
|
|
936
|
+
r.apply({ type: 'guide-delta', text: 'first', seq: 3 });
|
|
937
|
+
const userRow = r.state.messages[0];
|
|
938
|
+
r.apply({ type: 'guide-delta', text: ' more', seq: 4 });
|
|
939
|
+
expect(r.state.messages[0]).toBe(userRow);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('post-MESSAGE_END continuation emits a DELTA guide segment with append:true', () => {
|
|
943
|
+
const effects: Array<{ name: string; args: unknown[] }> = [];
|
|
944
|
+
const r = createChatStreamReducer({
|
|
945
|
+
transport: 'nats',
|
|
946
|
+
onEffect: e => effects.push(e),
|
|
947
|
+
});
|
|
948
|
+
r.apply({ type: 'turn-start', seq: 1 });
|
|
949
|
+
r.apply({ type: 'guide-delta', text: 'in-stream ', seq: 2 });
|
|
950
|
+
r.apply({ type: 'turn-end', seq: 3 });
|
|
951
|
+
r.apply({ type: 'guide-delta', text: 'post-end', seq: 4 });
|
|
952
|
+
|
|
953
|
+
const appendEmits = effects.filter(
|
|
954
|
+
e => e.name === 'onSegmentsUpdate' && (e.args[1] as { append?: boolean } | undefined)?.append === true,
|
|
955
|
+
);
|
|
956
|
+
expect(appendEmits).toHaveLength(1);
|
|
957
|
+
expect(appendEmits[0].args[0]).toEqual([{ type: 'guide', text: 'post-end' }]);
|
|
958
|
+
// …and the bubble carries ONE coalesced guide segment, not two.
|
|
959
|
+
const last = r.state.messages[r.state.messages.length - 1];
|
|
960
|
+
expect(last.segments).toEqual([{ type: 'guide', text: 'in-stream post-end' }]);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* `setMessages` is a RAW setter and must not be used to hydrate history.
|
|
966
|
+
*
|
|
967
|
+
* It assigns the thread and nothing else — in particular it does not mark the
|
|
968
|
+
* reducer resumed, which is what makes a later chunk with no preceding
|
|
969
|
+
* `turn-start` take the APPEND branch. Hydrating with it leaves the reducer
|
|
970
|
+
* looking cold, so the first such chunk goes down the cumulative path and
|
|
971
|
+
* REPLACES the restored bubble's segments. When that bubble holds a pending
|
|
972
|
+
* approval card, the control the agent is blocked on disappears with it and
|
|
973
|
+
* there is no way back.
|
|
974
|
+
*
|
|
975
|
+
* `initializeWithState` derives `resumed` from the restored thread, which is
|
|
976
|
+
* why every hydration path must go through it (the SSE adapter always did; the
|
|
977
|
+
* NATS adapter used `setMessages` and hit exactly this).
|
|
978
|
+
*/
|
|
889
979
|
describe('createChatStreamReducer — history hydration must declare resumed', () => {
|
|
890
980
|
const hydrated = () => [
|
|
891
981
|
{
|
|
@@ -207,7 +207,7 @@ const EMPTY_STATE: ChatReducerState = Object.freeze({
|
|
|
207
207
|
dialogTokenUsage: null,
|
|
208
208
|
liveModel: null,
|
|
209
209
|
approvalStatuses: Object.freeze({}) as ChatReducerState['approvalStatuses'],
|
|
210
|
-
});
|
|
210
|
+
}) as ChatReducerState;
|
|
211
211
|
|
|
212
212
|
/** Shared frozen empty list for a key with no send in flight. */
|
|
213
213
|
const EMPTY_PENDING_ECHOES: readonly PendingEcho[] = Object.freeze([]);
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
*/
|
|
52
52
|
|
|
53
53
|
import { escapeThinkingTags } from '../../../chat-protocol/decode';
|
|
54
|
-
import { errorDetailsMessage } from '../../../chat-protocol/events';
|
|
54
|
+
import { errorDetailsMessage, isGuideOrigin } from '../../../chat-protocol/events';
|
|
55
55
|
import {
|
|
56
56
|
CHAT_OWNER_ADMIN,
|
|
57
57
|
type ChatStreamEvent,
|
|
@@ -77,7 +77,7 @@ import type {
|
|
|
77
77
|
UnifiedChatMessage,
|
|
78
78
|
UnifiedUsageBreakdown,
|
|
79
79
|
} from '../types/unified-chat-state.types';
|
|
80
|
-
import { approvalDisplaysInline } from '../utils/approval-display';
|
|
80
|
+
import { approvalDisplaysInline, guideApprovalOrigin } from '../utils/approval-display';
|
|
81
81
|
import {
|
|
82
82
|
type MessageSegmentAccumulator,
|
|
83
83
|
createMessageSegmentAccumulator,
|
|
@@ -161,6 +161,15 @@ export interface ChatReducerState {
|
|
|
161
161
|
contextWindowMaxTokens: number | null;
|
|
162
162
|
} | null;
|
|
163
163
|
approvalStatuses: Record<string, ChatApprovalStatus>;
|
|
164
|
+
/**
|
|
165
|
+
* Hub conversation id for the Product Guide half of this dialog, learned from
|
|
166
|
+
* a guide metadata frame. The hub mints it and requires it back on every
|
|
167
|
+
* confirm-tool call, so a host resolving a guide approval card reads it from
|
|
168
|
+
* here. Null until a guide turn has streamed — an approval that arrives from
|
|
169
|
+
* history (after a reload) has no live frame to learn it from, which is why
|
|
170
|
+
* the agent should also expose it per dialog.
|
|
171
|
+
*/
|
|
172
|
+
guideConversationId: string | null;
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
/** Escalated-approval bookkeeping entry (mirrors the legacy processor). */
|
|
@@ -530,6 +539,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
530
539
|
let streamingPhase: StreamingPhase = 'idle';
|
|
531
540
|
let dialogTokenUsage: DialogTokenUsage | null = null;
|
|
532
541
|
let liveModel: ChatReducerState['liveModel'] = null;
|
|
542
|
+
let guideConversationId: string | null = null;
|
|
533
543
|
let approvalStatuses: Record<string, ChatApprovalStatus> = {
|
|
534
544
|
...(options.approvalStatuses ?? {}),
|
|
535
545
|
};
|
|
@@ -691,6 +701,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
691
701
|
dialogTokenUsage,
|
|
692
702
|
liveModel,
|
|
693
703
|
approvalStatuses,
|
|
704
|
+
guideConversationId,
|
|
694
705
|
};
|
|
695
706
|
}
|
|
696
707
|
return stateCache;
|
|
@@ -985,6 +996,17 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
985
996
|
}
|
|
986
997
|
|
|
987
998
|
case 'metadata': {
|
|
999
|
+
// A guide metadata event carries ONLY the hub's conversation id (the
|
|
1000
|
+
// decoder strips the rest): record it and stop. Falling through would
|
|
1001
|
+
// rebuild `liveModel` from an event with no model in it and blank the
|
|
1002
|
+
// dialog's model badge mid-answer.
|
|
1003
|
+
if (isGuideOrigin(event)) {
|
|
1004
|
+
if (typeof event.conversationId === 'string' && event.conversationId) {
|
|
1005
|
+
guideConversationId = event.conversationId;
|
|
1006
|
+
invalidate();
|
|
1007
|
+
}
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
988
1010
|
// Legacy `parseChunkToAction` action shape, reconstructed for the
|
|
989
1011
|
// callback contract.
|
|
990
1012
|
emit('onMetadata', {
|
|
@@ -1003,13 +1025,21 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1003
1025
|
break;
|
|
1004
1026
|
}
|
|
1005
1027
|
|
|
1006
|
-
//
|
|
1007
|
-
// slice into the trailing segment of the matching kind (the
|
|
1008
|
-
// coalesces), then emit.
|
|
1028
|
+
// The three APPEND-ONLY body streams share one shape: accumulate the
|
|
1029
|
+
// verbatim slice into the trailing segment of the matching kind (the
|
|
1030
|
+
// accumulator coalesces), then emit. `guide-delta` is NATS-only today
|
|
1031
|
+
// but rides the identical path so a guide body streams, coalesces and
|
|
1032
|
+
// continues post-MESSAGE_END exactly like answer text.
|
|
1009
1033
|
case 'text-delta':
|
|
1010
|
-
case 'thinking-delta':
|
|
1011
|
-
|
|
1012
|
-
const
|
|
1034
|
+
case 'thinking-delta':
|
|
1035
|
+
case 'guide-delta': {
|
|
1036
|
+
const kind = event.type === 'text-delta' ? 'text' : event.type === 'thinking-delta' ? 'thinking' : 'guide';
|
|
1037
|
+
const segments =
|
|
1038
|
+
kind === 'text'
|
|
1039
|
+
? accumulator.appendText(event.text)
|
|
1040
|
+
: kind === 'thinking'
|
|
1041
|
+
? accumulator.appendThinking(event.text)
|
|
1042
|
+
: accumulator.appendGuide(event.text);
|
|
1013
1043
|
// Append-mode only for *true* post-stream continuation (after a
|
|
1014
1044
|
// MESSAGE_END we actually saw). Cold-start chunks (no prior
|
|
1015
1045
|
// MESSAGE_START) emit cumulative segments so the consumer can spawn
|
|
@@ -1085,8 +1115,10 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1085
1115
|
const approvalType = event.approvalType ?? 'USER';
|
|
1086
1116
|
const toolCalls = event.toolCalls;
|
|
1087
1117
|
// Where this card renders is ONE rule, shared with the SSE kernel and
|
|
1088
|
-
// the history replay (`approval-display`)
|
|
1089
|
-
|
|
1118
|
+
// the history replay (`approval-display`) — a Product Guide proposal
|
|
1119
|
+
// that renders inline live must not move on the next page load.
|
|
1120
|
+
const guideOrigin = guideApprovalOrigin(event);
|
|
1121
|
+
const displayInline = (type: string) => approvalDisplaysInline(event, type, displayApprovalTypes);
|
|
1090
1122
|
|
|
1091
1123
|
if (toolCalls && toolCalls.length > 0) {
|
|
1092
1124
|
// ── Batch form ──
|
|
@@ -1118,6 +1150,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1118
1150
|
status,
|
|
1119
1151
|
undefined,
|
|
1120
1152
|
undefined,
|
|
1153
|
+
guideOrigin,
|
|
1121
1154
|
);
|
|
1122
1155
|
applyAccumulated(before, segments);
|
|
1123
1156
|
break;
|
|
@@ -1137,6 +1170,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1137
1170
|
approvalType,
|
|
1138
1171
|
status,
|
|
1139
1172
|
undefined,
|
|
1173
|
+
guideOrigin,
|
|
1140
1174
|
);
|
|
1141
1175
|
}
|
|
1142
1176
|
applyAccumulated(before, segments);
|
|
@@ -1155,8 +1189,10 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1155
1189
|
explanation,
|
|
1156
1190
|
approvalType,
|
|
1157
1191
|
status,
|
|
1158
|
-
// SSE-shaped cards
|
|
1192
|
+
// SSE-shaped cards (a re-streamed Product Guide proposal) carry
|
|
1193
|
+
// their body as structured rows rather than prose.
|
|
1159
1194
|
event.fields,
|
|
1195
|
+
guideOrigin,
|
|
1160
1196
|
);
|
|
1161
1197
|
applyAccumulated(before, segments);
|
|
1162
1198
|
} else {
|
|
@@ -1668,6 +1704,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1668
1704
|
streamingPhase = 'idle';
|
|
1669
1705
|
dialogTokenUsage = null;
|
|
1670
1706
|
liveModel = null;
|
|
1707
|
+
guideConversationId = null;
|
|
1671
1708
|
approvalStatuses = {};
|
|
1672
1709
|
accumulator.reset();
|
|
1673
1710
|
pendingEscalated.clear();
|
|
@@ -1715,6 +1752,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
|
|
|
1715
1752
|
streamingPhase = 'idle';
|
|
1716
1753
|
dialogTokenUsage = null;
|
|
1717
1754
|
liveModel = null;
|
|
1755
|
+
guideConversationId = null;
|
|
1718
1756
|
accumulator.reset();
|
|
1719
1757
|
pendingEscalated.clear();
|
|
1720
1758
|
isInStream = false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* createDeltaBatcher — framework-free, append-only batching of the
|
|
3
|
-
* `text-delta` / `thinking-delta` events.
|
|
3
|
+
* `text-delta` / `thinking-delta` / `guide-delta` events.
|
|
4
4
|
*
|
|
5
5
|
* WHY THIS IS SHARED (and not a hook-private detail): every host that drives a
|
|
6
6
|
* `ChatStreamReducer` from a raw transport needs the SAME batching, and a
|
|
@@ -23,14 +23,19 @@
|
|
|
23
23
|
* deltas never land on the wrong dialog/side.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
ChatStreamEvent,
|
|
28
|
+
GuideDeltaEvent,
|
|
29
|
+
TextDeltaEvent,
|
|
30
|
+
ThinkingDeltaEvent,
|
|
31
|
+
} from '../../../chat-protocol/events';
|
|
27
32
|
|
|
28
33
|
export const DELTA_FLUSH_FALLBACK_MS = 50;
|
|
29
34
|
|
|
30
|
-
export type DeltaEvent = TextDeltaEvent | ThinkingDeltaEvent;
|
|
35
|
+
export type DeltaEvent = TextDeltaEvent | ThinkingDeltaEvent | GuideDeltaEvent;
|
|
31
36
|
|
|
32
37
|
export function isDeltaEvent(event: ChatStreamEvent): event is DeltaEvent {
|
|
33
|
-
return event.type === 'text-delta' || event.type === 'thinking-delta';
|
|
38
|
+
return event.type === 'text-delta' || event.type === 'thinking-delta' || event.type === 'guide-delta';
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export interface CreateDeltaBatcherOptions<K> {
|
|
@@ -126,6 +126,8 @@ export function appendToTrailingAssistant(
|
|
|
126
126
|
merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text };
|
|
127
127
|
} else if (seg.type === 'thinking' && tail?.type === 'thinking') {
|
|
128
128
|
merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text };
|
|
129
|
+
} else if (seg.type === 'guide' && tail?.type === 'guide') {
|
|
130
|
+
merged[merged.length - 1] = { type: 'guide', text: tail.text + seg.text };
|
|
129
131
|
} else if (key) {
|
|
130
132
|
// Block deltas must be IDEMPOTENT: an emit can be seen twice (live plus
|
|
131
133
|
// the JetStream catch-up replay over hydrated history), so upsert on the
|
|
@@ -578,6 +578,15 @@ export interface ThinkingDisplayProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
578
578
|
isStreaming?: boolean;
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
// ========== Guide Display Props ==========
|
|
582
|
+
|
|
583
|
+
export interface GuideDisplayProps extends HTMLAttributes<HTMLDivElement> {
|
|
584
|
+
/** Rendered guide body. `ChatMessageEnhanced` passes the SAME markdown
|
|
585
|
+
* output a text segment gets, so `[card://]` cards and mention chips
|
|
586
|
+
* behave identically inside a guide. */
|
|
587
|
+
children?: ReactNode;
|
|
588
|
+
}
|
|
589
|
+
|
|
581
590
|
// ========== Ask Display Props ==========
|
|
582
591
|
|
|
583
592
|
export interface AskDisplayProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> {
|
|
@@ -10,6 +10,7 @@ import type { AssistantType, AuthorType, ChatApprovalStatus, MessageOwner } from
|
|
|
10
10
|
export const MESSAGE_TYPE = {
|
|
11
11
|
TEXT: 'TEXT',
|
|
12
12
|
THINKING: 'THINKING',
|
|
13
|
+
GUIDE: 'GUIDE',
|
|
13
14
|
ASK: 'ASK',
|
|
14
15
|
EXECUTING_TOOL: 'EXECUTING_TOOL',
|
|
15
16
|
EXECUTED_TOOL: 'EXECUTED_TOOL',
|
|
@@ -113,6 +114,12 @@ export interface ApprovalRequestData {
|
|
|
113
114
|
requestId?: string;
|
|
114
115
|
approvalRequestId?: string;
|
|
115
116
|
approvalType?: string;
|
|
117
|
+
/** `'guide'` when the card came from a Product Guide frame (see `GuideOrigin`
|
|
118
|
+
* in `chat-protocol/events`). Carried onto the segment because hosts route
|
|
119
|
+
* and filter approvals by where they came from: mingo lifts its OWN pending
|
|
120
|
+
* cards into a sticky footer, and a guide card must stay inline in the turn
|
|
121
|
+
* the way it does in the hub's chat. */
|
|
122
|
+
origin?: 'guide';
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
export interface ApprovalResultData {
|
|
@@ -220,6 +227,11 @@ export interface ApprovalBatchData {
|
|
|
220
227
|
* batch itself is approved.
|
|
221
228
|
*/
|
|
222
229
|
executions?: Record<string, ApprovalBatchExecutionState>;
|
|
230
|
+
/** `'guide'` when the batch came from a Product Guide `approval_batch` frame.
|
|
231
|
+
* Same contract and the same reasons as `ApprovalRequestData.origin` — the
|
|
232
|
+
* hub groups multiple proposals into one card, and that card resolves
|
|
233
|
+
* through the hub exactly like a single one. */
|
|
234
|
+
origin?: 'guide';
|
|
223
235
|
}
|
|
224
236
|
|
|
225
237
|
/** Approve/reject handler stamped onto approval segments. MAY resolve a
|
|
@@ -242,6 +254,15 @@ export type ThinkingSegment = {
|
|
|
242
254
|
text: string;
|
|
243
255
|
};
|
|
244
256
|
|
|
257
|
+
/** Guide answer body — the assistant's how-to/documentation reply, rendered as
|
|
258
|
+
* a titled "OpenFrame Guide" card instead of a bare paragraph. `text` is
|
|
259
|
+
* markdown, streamed in fragments like a `text` segment and coalesced by the
|
|
260
|
+
* accumulator. */
|
|
261
|
+
export type GuideSegment = {
|
|
262
|
+
type: 'guide';
|
|
263
|
+
text: string;
|
|
264
|
+
};
|
|
265
|
+
|
|
245
266
|
/** One reading the assistant offers in an `ask` card. `label` is BOTH the row's
|
|
246
267
|
* headline and the exact text sent back when the row is picked — the backend's
|
|
247
268
|
* guide classifier resolves the user's next message against the labels it
|
|
@@ -359,6 +380,7 @@ export type ContextCompactionSegment = {
|
|
|
359
380
|
export type MessageSegment =
|
|
360
381
|
| TextSegment
|
|
361
382
|
| ThinkingSegment
|
|
383
|
+
| GuideSegment
|
|
362
384
|
| AskSegment
|
|
363
385
|
| ToolExecutionSegment
|
|
364
386
|
| ApprovalRequestSegment
|
|
@@ -391,6 +413,15 @@ export interface ThinkingMessageData extends MessageDataBase {
|
|
|
391
413
|
text?: string;
|
|
392
414
|
}
|
|
393
415
|
|
|
416
|
+
export interface GuideMessageData extends MessageDataBase {
|
|
417
|
+
type: 'GUIDE';
|
|
418
|
+
text?: string;
|
|
419
|
+
/** Persisted Product Guide frame (GraphQL `GuideData.payload`, a JSON scalar)
|
|
420
|
+
* for rows that carry a card instead of answer text — replayed through the
|
|
421
|
+
* live path's `guideFrameEvent`. */
|
|
422
|
+
payload?: Record<string, unknown>;
|
|
423
|
+
}
|
|
424
|
+
|
|
394
425
|
/** Persisted `ASK` row (GraphQL `AskData`). `text` is the intro sentence, which
|
|
395
426
|
* history replays as a text segment ahead of the card — same split the live
|
|
396
427
|
* `ASK` chunk carries. */
|
|
@@ -532,6 +563,7 @@ export interface ContextCompactionEndMessageData extends MessageDataBase {
|
|
|
532
563
|
export type MessageData =
|
|
533
564
|
| TextMessageData
|
|
534
565
|
| ThinkingMessageData
|
|
566
|
+
| GuideMessageData
|
|
535
567
|
| AskMessageData
|
|
536
568
|
| ExecutingToolMessageData
|
|
537
569
|
| ExecutedToolMessageData
|
|
@@ -63,7 +63,9 @@ export interface ChunkData {
|
|
|
63
63
|
/** Batch-approval rows. Coerced by `normalizeToolCalls` in the NATS decoder
|
|
64
64
|
* — the wire shape is per-backend, so entries stay opaque here. */
|
|
65
65
|
toolCalls?: unknown[];
|
|
66
|
-
/**
|
|
66
|
+
/** On GUIDE chunks: a Product Guide frame the agent re-streamed verbatim from
|
|
67
|
+
* the hub, instead of the answer text `text` carries. Decoded by
|
|
68
|
+
* `decodeNatsChunk` through the shared leading-frame table. */
|
|
67
69
|
payload?: Record<string, unknown>;
|
|
68
70
|
modelName?: string;
|
|
69
71
|
providerName?: string;
|
|
@@ -18,9 +18,12 @@ export interface PendingApproval {
|
|
|
18
18
|
command: string;
|
|
19
19
|
explanation?: string;
|
|
20
20
|
approvalType: string;
|
|
21
|
-
/** Structured label/value rows — a card
|
|
21
|
+
/** Structured label/value rows — a Product Guide card is almost entirely
|
|
22
22
|
* these, so a path that forgets them renders a bare title. */
|
|
23
23
|
fields?: ApprovalRequestField[];
|
|
24
|
+
/** Where the card came from; `'guide'` keeps it inline and routes its
|
|
25
|
+
* buttons to the hub (see `ApprovalRequestData.origin`). */
|
|
26
|
+
origin?: 'guide';
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface AccumulatorState {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { decodeNatsChunk } from '../../../../chat-protocol/nats-decoder';
|
|
12
13
|
import { createChatStreamReducer } from '../../stream/chat-stream-reducer';
|
|
13
14
|
import type { HistoricalMessage, MessageSegment } from '../../types';
|
|
14
15
|
import { processHistoricalMessagesWithErrors } from '../process-historical-messages';
|
|
@@ -150,3 +151,95 @@ describe('displayApprovalTypes — history vs realtime default divergence', () =
|
|
|
150
151
|
expect(live.escalated).toHaveLength(0);
|
|
151
152
|
});
|
|
152
153
|
});
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A Product Guide card must survive a reload UNCHANGED.
|
|
157
|
+
*
|
|
158
|
+
* It arrives as a hub frame the agent re-streams into the dialog, so it is
|
|
159
|
+
* typed by TOOL and resolves through the hub's confirm route — the tier gate
|
|
160
|
+
* these tests are about simply does not apply to it. When the rule lived only
|
|
161
|
+
* in the realtime kernel, the same card rendered inline while streaming and
|
|
162
|
+
* then, on the next page load, was tier-gated: lifted out of its turn into the
|
|
163
|
+
* consumer's sticky pending-approvals envelope, stripped of the `fields` that
|
|
164
|
+
* are its entire body, and stripped of the `origin` that routes its buttons —
|
|
165
|
+
* so clicking Approve hit the agent's endpoint and 404'd.
|
|
166
|
+
*/
|
|
167
|
+
describe('Product Guide approvals — history and realtime agree', () => {
|
|
168
|
+
const guideProposalMessage = (id: string): HistoricalMessage => ({
|
|
169
|
+
id,
|
|
170
|
+
createdAt: '2026-07-17T12:00:00Z',
|
|
171
|
+
owner: { type: 'ASSISTANT' },
|
|
172
|
+
messageData: [
|
|
173
|
+
{
|
|
174
|
+
type: 'GUIDE',
|
|
175
|
+
payload: {
|
|
176
|
+
kind: 'approval_request',
|
|
177
|
+
proposalId: 'prop-1',
|
|
178
|
+
toolName: 'create_ticket',
|
|
179
|
+
title: 'Open a support ticket',
|
|
180
|
+
fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// The narrowest possible tier list: it excludes the guide's tool name, so a
|
|
187
|
+
// card that still renders proves the gate was bypassed, not merely satisfied.
|
|
188
|
+
const NARROW_TIERS = ['CLIENT'];
|
|
189
|
+
|
|
190
|
+
it('renders inline on reload despite a tier list that excludes it', () => {
|
|
191
|
+
const { messages, escalatedApprovals } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
|
|
192
|
+
displayApprovalTypes: NARROW_TIERS,
|
|
193
|
+
});
|
|
194
|
+
const rendered = messages.flatMap(m => approvalSegments(m.content));
|
|
195
|
+
expect(rendered).toHaveLength(1);
|
|
196
|
+
expect(escalatedApprovals.size).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('keeps the card in its own turn, not in the sticky pending-approvals envelope', () => {
|
|
200
|
+
const { messages } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
|
|
201
|
+
displayApprovalTypes: NARROW_TIERS,
|
|
202
|
+
});
|
|
203
|
+
expect(messages.some(m => String(m.id).startsWith('pending-approvals-'))).toBe(false);
|
|
204
|
+
const turn = messages.find(m => m.id === 'm1');
|
|
205
|
+
expect(approvalSegments(turn?.content)).toHaveLength(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('carries the same fields and origin as the live stream', () => {
|
|
209
|
+
/** The single card's `data`, narrowed off the segment union. */
|
|
210
|
+
const cardData = (segments: MessageSegment[]) => {
|
|
211
|
+
const card = segments.find(s => s.type === 'approval_request');
|
|
212
|
+
return card?.type === 'approval_request' ? card.data : undefined;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const { messages } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
|
|
216
|
+
displayApprovalTypes: NARROW_TIERS,
|
|
217
|
+
});
|
|
218
|
+
const replayed = cardData(messages.flatMap(m => approvalSegments(m.content)));
|
|
219
|
+
|
|
220
|
+
const live = createChatStreamReducer({ transport: 'nats', displayApprovalTypes: NARROW_TIERS });
|
|
221
|
+
live.apply({ type: 'turn-start' });
|
|
222
|
+
const guideChunk = decodeNatsChunk({
|
|
223
|
+
type: 'GUIDE',
|
|
224
|
+
payload: {
|
|
225
|
+
kind: 'approval_request',
|
|
226
|
+
proposalId: 'prop-1',
|
|
227
|
+
toolName: 'create_ticket',
|
|
228
|
+
title: 'Open a support ticket',
|
|
229
|
+
fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
// A decoder that declines this frame is the failure the test exists to
|
|
233
|
+
// catch — say so rather than applying `undefined` to the reducer.
|
|
234
|
+
if (!guideChunk) throw new Error('decodeNatsChunk did not decode the GUIDE approval_request frame');
|
|
235
|
+
live.apply(guideChunk);
|
|
236
|
+
const streamed = cardData(live.state.messages.flatMap(m => approvalSegments(m.segments)));
|
|
237
|
+
|
|
238
|
+
// Same card either way — down to the rows that ARE its body, and the marker
|
|
239
|
+
// that decides which backend its buttons talk to.
|
|
240
|
+
expect(replayed).toEqual(streamed);
|
|
241
|
+
expect(replayed?.origin).toBe('guide');
|
|
242
|
+
expect(replayed?.approvalType).toBe('create_ticket');
|
|
243
|
+
expect(replayed?.fields).toEqual([{ label: 'Subject', value: 'Agent will not enroll' }]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -318,9 +318,86 @@ describe('extractErrorMessages / processHistoricalMessagesWithErrors — golden'
|
|
|
318
318
|
});
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
// ─── GUIDE rows (#1583) ────────────────────────────────────────────────────
|
|
322
|
+
//
|
|
323
|
+
// The realtime path decodes a `GUIDE` chunk into a `guide-delta` event; the
|
|
324
|
+
// history path must decode the persisted `GUIDE` row into the SAME event so a
|
|
325
|
+
// reloaded dialog renders the guide card instead of silently dropping it.
|
|
326
|
+
// Explicit assertions rather than a corpus snapshot — the point is the exact
|
|
327
|
+
// coalescing shape, not the whole-dialog record.
|
|
328
|
+
|
|
329
|
+
describe('processHistoricalMessages — GUIDE rows', () => {
|
|
330
|
+
const guideDialog: HistoricalMessage[] = [
|
|
331
|
+
{
|
|
332
|
+
id: 'u1',
|
|
333
|
+
chatType: 'CLIENT_CHAT',
|
|
334
|
+
createdAt: '2026-07-19T09:00:00Z',
|
|
335
|
+
owner: { type: 'CLIENT', userId: 'user-42' },
|
|
336
|
+
messageData: [{ type: 'TEXT', text: 'how do I enroll a device?' }],
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: 'a1',
|
|
340
|
+
chatType: 'CLIENT_CHAT',
|
|
341
|
+
createdAt: '2026-07-19T09:00:05Z',
|
|
342
|
+
owner: { type: 'ASSISTANT', model: 'claude-sonnet-x' },
|
|
343
|
+
messageData: [
|
|
344
|
+
{ type: 'GUIDE', text: '## Enroll a device\n' },
|
|
345
|
+
{ type: 'GUIDE', text: '1. Open Settings' },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
it('replays consecutive GUIDE rows into ONE coalesced guide segment', () => {
|
|
351
|
+
const { messages } = processHistoricalMessages(guideDialog, {
|
|
352
|
+
assistantName: 'Mingo AI',
|
|
353
|
+
assistantType: 'mingo',
|
|
354
|
+
});
|
|
355
|
+
expect(messages).toHaveLength(2);
|
|
356
|
+
expect(messages[1]).toMatchObject({
|
|
357
|
+
role: 'assistant',
|
|
358
|
+
content: [{ type: 'guide', text: '## Enroll a device\n1. Open Settings' }],
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('keeps GUIDE and TEXT as separate segments in wire order', () => {
|
|
363
|
+
const mixed: HistoricalMessage[] = [
|
|
364
|
+
{
|
|
365
|
+
id: 'a1',
|
|
366
|
+
chatType: 'CLIENT_CHAT',
|
|
367
|
+
createdAt: '2026-07-19T09:00:05Z',
|
|
368
|
+
owner: { type: 'ASSISTANT' },
|
|
369
|
+
messageData: [
|
|
370
|
+
{ type: 'TEXT', text: 'Here is the walkthrough.' },
|
|
371
|
+
{ type: 'GUIDE', text: '## Steps' },
|
|
372
|
+
{ type: 'TEXT', text: 'Anything else?' },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
const { messages } = processHistoricalMessages(mixed);
|
|
377
|
+
expect(messages[0].content).toEqual([
|
|
378
|
+
{ type: 'text', text: 'Here is the walkthrough.' },
|
|
379
|
+
{ type: 'guide', text: '## Steps' },
|
|
380
|
+
{ type: 'text', text: 'Anything else?' },
|
|
381
|
+
]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('a GUIDE row with no text decodes to nothing (no empty card)', () => {
|
|
385
|
+
const empty: HistoricalMessage[] = [
|
|
386
|
+
{
|
|
387
|
+
id: 'a1',
|
|
388
|
+
chatType: 'CLIENT_CHAT',
|
|
389
|
+
createdAt: '2026-07-19T09:00:05Z',
|
|
390
|
+
owner: { type: 'ASSISTANT' },
|
|
391
|
+
messageData: [{ type: 'GUIDE' }],
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
expect(processHistoricalMessages(empty).messages).toEqual([]);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
321
398
|
// ─── ASK rows ──────────────────────────────────────────────────────────────
|
|
322
399
|
//
|
|
323
|
-
//
|
|
400
|
+
// Same contract as GUIDE above: history must replay a persisted `ASK` row into
|
|
324
401
|
// the SAME shape the live `ASK` chunk produces — the intro sentence as answer
|
|
325
402
|
// text, then the card — or a reloaded dialog loses the clarification the user
|
|
326
403
|
// was about to answer.
|