@flamingo-stack/openframe-frontend-core 0.0.532 → 0.0.533

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.
Files changed (151) hide show
  1. package/dist/chat-protocol/confirm-tool.d.ts +50 -0
  2. package/dist/chat-protocol/confirm-tool.d.ts.map +1 -0
  3. package/dist/chat-protocol/decode.d.ts +1 -9
  4. package/dist/chat-protocol/decode.d.ts.map +1 -1
  5. package/dist/chat-protocol/events.d.ts +35 -0
  6. package/dist/chat-protocol/events.d.ts.map +1 -1
  7. package/dist/chat-protocol/index.cjs +72 -3
  8. package/dist/chat-protocol/index.cjs.map +1 -1
  9. package/dist/chat-protocol/index.d.ts +1 -0
  10. package/dist/chat-protocol/index.d.ts.map +1 -1
  11. package/dist/chat-protocol/index.js +67 -4
  12. package/dist/chat-protocol/index.js.map +1 -1
  13. package/dist/chat-protocol/leading-frames.d.ts +38 -0
  14. package/dist/chat-protocol/leading-frames.d.ts.map +1 -0
  15. package/dist/chat-protocol/nats-decoder.d.ts +41 -0
  16. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  17. package/dist/{chunk-QEMI34VA.cjs → chunk-2MYDGLJS.cjs} +87 -87
  18. package/dist/{chunk-QEMI34VA.cjs.map → chunk-2MYDGLJS.cjs.map} +1 -1
  19. package/dist/{chunk-TQ7EPKKO.js → chunk-2TUOBFPK.js} +2 -2
  20. package/dist/{chunk-XBYEPSNZ.js → chunk-2XGHEJZK.js} +4 -4
  21. package/dist/{chunk-OAEJWP7H.js → chunk-46FKXLNE.js} +2 -2
  22. package/dist/{chunk-ECR37JHH.js → chunk-4QKXNWH5.js} +2 -2
  23. package/dist/{chunk-GWFC3NE5.cjs → chunk-4ZRHVOEB.cjs} +31 -31
  24. package/dist/{chunk-GWFC3NE5.cjs.map → chunk-4ZRHVOEB.cjs.map} +1 -1
  25. package/dist/{chunk-AZBS4PZG.js → chunk-5U67WTAS.js} +2 -2
  26. package/dist/{chunk-3G7FDE5E.js → chunk-6SMLYZLH.js} +4 -4
  27. package/dist/{chunk-7SGBF2I2.js → chunk-6W3ULZ4X.js} +6 -6
  28. package/dist/{chunk-ALXLWF75.js → chunk-6YXN5RHW.js} +2 -2
  29. package/dist/{chunk-2QP67D2L.cjs → chunk-DFQLUK75.cjs} +3 -3
  30. package/dist/{chunk-2QP67D2L.cjs.map → chunk-DFQLUK75.cjs.map} +1 -1
  31. package/dist/{chunk-ETF2QXXU.cjs → chunk-ENPGPZIB.cjs} +61 -61
  32. package/dist/{chunk-ETF2QXXU.cjs.map → chunk-ENPGPZIB.cjs.map} +1 -1
  33. package/dist/{chunk-HJDCDOJM.cjs → chunk-GNOK5J34.cjs} +37 -37
  34. package/dist/{chunk-HJDCDOJM.cjs.map → chunk-GNOK5J34.cjs.map} +1 -1
  35. package/dist/{chunk-4GL2XJ5C.cjs → chunk-J4BE4FIR.cjs} +4 -4
  36. package/dist/{chunk-4GL2XJ5C.cjs.map → chunk-J4BE4FIR.cjs.map} +1 -1
  37. package/dist/{chunk-HXHA3DCI.cjs → chunk-JDW2IL7A.cjs} +11 -11
  38. package/dist/{chunk-HXHA3DCI.cjs.map → chunk-JDW2IL7A.cjs.map} +1 -1
  39. package/dist/{chunk-TMZS63G2.cjs → chunk-KMB66NYX.cjs} +7 -7
  40. package/dist/{chunk-TMZS63G2.cjs.map → chunk-KMB66NYX.cjs.map} +1 -1
  41. package/dist/{chunk-5W3RCZIK.js → chunk-LE3BJ5H2.js} +2 -2
  42. package/dist/{chunk-WINT757E.js → chunk-LIUARTOZ.js} +206 -64
  43. package/dist/chunk-LIUARTOZ.js.map +1 -0
  44. package/dist/{chunk-65NR333L.js → chunk-MVGL5NNJ.js} +2 -2
  45. package/dist/{chunk-LYWSHNK4.cjs → chunk-NAV4FRNL.cjs} +26 -26
  46. package/dist/{chunk-LYWSHNK4.cjs.map → chunk-NAV4FRNL.cjs.map} +1 -1
  47. package/dist/{chunk-2MBS3YEY.cjs → chunk-OLOKQQAP.cjs} +5 -5
  48. package/dist/{chunk-2MBS3YEY.cjs.map → chunk-OLOKQQAP.cjs.map} +1 -1
  49. package/dist/{chunk-QYNLVIGQ.cjs → chunk-QZ2NAPLF.cjs} +9 -9
  50. package/dist/{chunk-QYNLVIGQ.cjs.map → chunk-QZ2NAPLF.cjs.map} +1 -1
  51. package/dist/{chunk-L6EKEYF7.cjs → chunk-RKITTO5V.cjs} +14 -14
  52. package/dist/{chunk-L6EKEYF7.cjs.map → chunk-RKITTO5V.cjs.map} +1 -1
  53. package/dist/{chunk-UP4RA54U.js → chunk-U555TMPC.js} +5 -5
  54. package/dist/{chunk-GVLA2BPW.cjs → chunk-UPHN2VRZ.cjs} +259 -117
  55. package/dist/chunk-UPHN2VRZ.cjs.map +1 -0
  56. package/dist/{chunk-IZOGFZ7N.js → chunk-XN4JKIJY.js} +2 -2
  57. package/dist/{chunk-KYQZBQYV.js → chunk-YLB6XXO6.js} +2 -2
  58. package/dist/{chunk-JZ5XDZYD.cjs → chunk-Z3NOI7X7.cjs} +26 -26
  59. package/dist/{chunk-JZ5XDZYD.cjs.map → chunk-Z3NOI7X7.cjs.map} +1 -1
  60. package/dist/components/case-studies/index.cjs +8 -8
  61. package/dist/components/case-studies/index.js +2 -2
  62. package/dist/components/chat/embeddable-chat.d.ts +13 -0
  63. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  64. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  65. package/dist/components/chat/index.cjs +10 -2
  66. package/dist/components/chat/index.cjs.map +1 -1
  67. package/dist/components/chat/index.js +9 -1
  68. package/dist/components/chat/stream/chat-stream-reducer.d.ts +9 -0
  69. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  70. package/dist/components/chat/types/message.types.d.ts +15 -0
  71. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  72. package/dist/components/chat/types/network.types.d.ts +4 -0
  73. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  74. package/dist/components/chat/types/processing.types.d.ts +7 -1
  75. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  76. package/dist/components/chat/utils/approval-display.d.ts +49 -0
  77. package/dist/components/chat/utils/approval-display.d.ts.map +1 -0
  78. package/dist/components/chat/utils/index.d.ts +1 -0
  79. package/dist/components/chat/utils/index.d.ts.map +1 -1
  80. package/dist/components/chat/utils/message-segment-accumulator.d.ts +25 -3
  81. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  82. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  83. package/dist/components/contact/index.cjs +3 -3
  84. package/dist/components/contact/index.js +2 -2
  85. package/dist/components/docs/index.cjs +5 -5
  86. package/dist/components/docs/index.js +4 -4
  87. package/dist/components/embeds/index.cjs +3 -3
  88. package/dist/components/embeds/index.js +2 -2
  89. package/dist/components/faq/index.cjs +3 -3
  90. package/dist/components/faq/index.js +2 -2
  91. package/dist/components/features/index.cjs +2 -2
  92. package/dist/components/features/index.js +1 -1
  93. package/dist/components/help-center-pages/index.cjs +22 -22
  94. package/dist/components/help-center-pages/index.js +13 -13
  95. package/dist/components/index.cjs +140 -132
  96. package/dist/components/index.cjs.map +1 -1
  97. package/dist/components/index.js +19 -11
  98. package/dist/components/index.js.map +1 -1
  99. package/dist/components/meeting-scheduler/index.cjs +34 -34
  100. package/dist/components/meeting-scheduler/index.js +3 -3
  101. package/dist/components/navigation/index.cjs +2 -2
  102. package/dist/components/navigation/index.js +1 -1
  103. package/dist/components/onboarding-guides/index.cjs +5 -5
  104. package/dist/components/onboarding-guides/index.js +4 -4
  105. package/dist/components/related-content/index.cjs +3 -3
  106. package/dist/components/related-content/index.js +2 -2
  107. package/dist/components/tickets/index.cjs +6 -6
  108. package/dist/components/tickets/index.js +5 -5
  109. package/dist/components/ui/index.cjs +10 -2
  110. package/dist/components/ui/index.cjs.map +1 -1
  111. package/dist/components/ui/index.js +9 -1
  112. package/dist/index.cjs +10 -2
  113. package/dist/index.cjs.map +1 -1
  114. package/dist/index.js +9 -1
  115. package/dist/utils/index.cjs.map +1 -1
  116. package/dist/utils/index.js.map +1 -1
  117. package/package.json +1 -1
  118. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +70 -0
  119. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +108 -1
  120. package/src/chat-protocol/confirm-tool.ts +67 -0
  121. package/src/chat-protocol/decode.ts +7 -153
  122. package/src/chat-protocol/events.ts +40 -0
  123. package/src/chat-protocol/index.ts +3 -0
  124. package/src/chat-protocol/leading-frames.ts +175 -0
  125. package/src/chat-protocol/nats-decoder.ts +111 -3
  126. package/src/components/chat/embeddable-chat.tsx +18 -0
  127. package/src/components/chat/hooks/use-sse-chat-adapter.ts +7 -16
  128. package/src/components/chat/stream/chat-stream-reducer.ts +49 -3
  129. package/src/components/chat/types/message.types.ts +15 -0
  130. package/src/components/chat/types/network.types.ts +4 -0
  131. package/src/components/chat/types/processing.types.ts +12 -1
  132. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +92 -0
  133. package/src/components/chat/utils/approval-display.ts +70 -0
  134. package/src/components/chat/utils/index.ts +9 -0
  135. package/src/components/chat/utils/message-segment-accumulator.ts +62 -32
  136. package/src/components/chat/utils/process-historical-messages.ts +40 -6
  137. package/dist/chunk-GVLA2BPW.cjs.map +0 -1
  138. package/dist/chunk-WINT757E.js.map +0 -1
  139. /package/dist/{chunk-TQ7EPKKO.js.map → chunk-2TUOBFPK.js.map} +0 -0
  140. /package/dist/{chunk-XBYEPSNZ.js.map → chunk-2XGHEJZK.js.map} +0 -0
  141. /package/dist/{chunk-OAEJWP7H.js.map → chunk-46FKXLNE.js.map} +0 -0
  142. /package/dist/{chunk-ECR37JHH.js.map → chunk-4QKXNWH5.js.map} +0 -0
  143. /package/dist/{chunk-AZBS4PZG.js.map → chunk-5U67WTAS.js.map} +0 -0
  144. /package/dist/{chunk-3G7FDE5E.js.map → chunk-6SMLYZLH.js.map} +0 -0
  145. /package/dist/{chunk-7SGBF2I2.js.map → chunk-6W3ULZ4X.js.map} +0 -0
  146. /package/dist/{chunk-ALXLWF75.js.map → chunk-6YXN5RHW.js.map} +0 -0
  147. /package/dist/{chunk-5W3RCZIK.js.map → chunk-LE3BJ5H2.js.map} +0 -0
  148. /package/dist/{chunk-65NR333L.js.map → chunk-MVGL5NNJ.js.map} +0 -0
  149. /package/dist/{chunk-UP4RA54U.js.map → chunk-U555TMPC.js.map} +0 -0
  150. /package/dist/{chunk-IZOGFZ7N.js.map → chunk-XN4JKIJY.js.map} +0 -0
  151. /package/dist/{chunk-KYQZBQYV.js.map → chunk-YLB6XXO6.js.map} +0 -0
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { describe, it, expect } from 'vitest'
12
12
  import { processHistoricalMessagesWithErrors } from '../process-historical-messages'
13
+ import { decodeNatsChunk } from '../../../../chat-protocol/nats-decoder'
13
14
  import { createChatStreamReducer } from '../../stream/chat-stream-reducer'
14
15
  import type { HistoricalMessage, MessageSegment } from '../../types'
15
16
 
@@ -156,3 +157,94 @@ describe('displayApprovalTypes — history vs realtime default divergence', () =
156
157
  expect(live.escalated).toHaveLength(0)
157
158
  })
158
159
  })
160
+
161
+ /**
162
+ * A Product Guide card must survive a reload UNCHANGED.
163
+ *
164
+ * It arrives as a hub frame the agent re-streams into the dialog, so it is
165
+ * typed by TOOL and resolves through the hub's confirm route — the tier gate
166
+ * these tests are about simply does not apply to it. When the rule lived only
167
+ * in the realtime kernel, the same card rendered inline while streaming and
168
+ * then, on the next page load, was tier-gated: lifted out of its turn into the
169
+ * consumer's sticky pending-approvals envelope, stripped of the `fields` that
170
+ * are its entire body, and stripped of the `origin` that routes its buttons —
171
+ * so clicking Approve hit the agent's endpoint and 404'd.
172
+ */
173
+ describe('Product Guide approvals — history and realtime agree', () => {
174
+ const guideProposalMessage = (id: string): HistoricalMessage => ({
175
+ id,
176
+ createdAt: '2026-07-17T12:00:00Z',
177
+ owner: { type: 'ASSISTANT' },
178
+ messageData: [
179
+ {
180
+ type: 'GUIDE',
181
+ payload: {
182
+ kind: 'approval_request',
183
+ proposalId: 'prop-1',
184
+ toolName: 'create_ticket',
185
+ title: 'Open a support ticket',
186
+ fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
187
+ },
188
+ } as any,
189
+ ],
190
+ })
191
+
192
+ // The narrowest possible tier list: it excludes the guide's tool name, so a
193
+ // card that still renders proves the gate was bypassed, not merely satisfied.
194
+ const NARROW_TIERS = ['CLIENT']
195
+
196
+ it('renders inline on reload despite a tier list that excludes it', () => {
197
+ const { messages, escalatedApprovals } = processHistoricalMessagesWithErrors(
198
+ [guideProposalMessage('m1')],
199
+ { displayApprovalTypes: NARROW_TIERS },
200
+ )
201
+ const rendered = messages.flatMap((m) => approvalSegments(m.content))
202
+ expect(rendered).toHaveLength(1)
203
+ expect(escalatedApprovals.size).toBe(0)
204
+ })
205
+
206
+ it('keeps the card in its own turn, not in the sticky pending-approvals envelope', () => {
207
+ const { messages } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
208
+ displayApprovalTypes: NARROW_TIERS,
209
+ })
210
+ expect(messages.some((m) => String(m.id).startsWith('pending-approvals-'))).toBe(false)
211
+ const turn = messages.find((m) => m.id === 'm1')
212
+ expect(approvalSegments(turn?.content)).toHaveLength(1)
213
+ })
214
+
215
+ it('carries the same fields and origin as the live stream', () => {
216
+ /** The single card's `data`, narrowed off the segment union. */
217
+ const cardData = (segments: MessageSegment[]) => {
218
+ const card = segments.find((s) => s.type === 'approval_request')
219
+ return card?.type === 'approval_request' ? card.data : undefined
220
+ }
221
+
222
+ const { messages } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
223
+ displayApprovalTypes: NARROW_TIERS,
224
+ })
225
+ const replayed = cardData(messages.flatMap((m) => approvalSegments(m.content)))
226
+
227
+ const live = createChatStreamReducer({ transport: 'nats', displayApprovalTypes: NARROW_TIERS })
228
+ live.apply({ type: 'turn-start' })
229
+ live.apply(
230
+ decodeNatsChunk({
231
+ type: 'GUIDE',
232
+ payload: {
233
+ kind: 'approval_request',
234
+ proposalId: 'prop-1',
235
+ toolName: 'create_ticket',
236
+ title: 'Open a support ticket',
237
+ fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
238
+ },
239
+ })!,
240
+ )
241
+ const streamed = cardData(live.state.messages.flatMap((m) => approvalSegments(m.segments)))
242
+
243
+ // Same card either way — down to the rows that ARE its body, and the marker
244
+ // that decides which backend its buttons talk to.
245
+ expect(replayed).toEqual(streamed)
246
+ expect(replayed?.origin).toBe('guide')
247
+ expect(replayed?.approvalType).toBe('create_ticket')
248
+ expect(replayed?.fields).toEqual([{ label: 'Subject', value: 'Agent will not enroll' }])
249
+ })
250
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * WHERE an approval card renders — the one rule, read by every path that turns
3
+ * an approval into segments.
4
+ *
5
+ * There are three such paths and they must agree: the NATS kernel (live chunks),
6
+ * the SSE kernel, and the history replay that rebuilds a dialog after a reload.
7
+ * When the rule lived only in the live kernel, a Product Guide card rendered
8
+ * inline while streaming and then moved into the escalation treatment on the
9
+ * next page load — same card, two behaviors, and its buttons pointed at the
10
+ * wrong backend once it moved.
11
+ *
12
+ * The rule itself: a card is gated on approval TIER (`displayApprovalTypes`,
13
+ * the agent's own privilege model) UNLESS it came from the Product Guide, whose
14
+ * proposals are typed by TOOL and are resolved through the hub's confirm route.
15
+ * A hub proposal has no tier to gate on, so tier-gating one means escalating it
16
+ * to a human who has no way to act on it.
17
+ */
18
+
19
+ import type { GuideOrigin } from '../../../chat-protocol/events'
20
+ import { GUIDE_ORIGIN, isGuideOrigin } from '../../../chat-protocol/events'
21
+ import type { MessageSegment } from '../types/message.types'
22
+
23
+ /** Anything carrying the optional `origin` marker: a stream event, or the
24
+ * `data` of a rendered segment. */
25
+ interface OriginBearing {
26
+ origin?: GuideOrigin | string
27
+ }
28
+
29
+ /** True when this approval was minted by the Product Guide (the hub) rather
30
+ * than by the agent's own tool-approval tiering. */
31
+ export function isGuideApproval(source: OriginBearing | null | undefined): boolean {
32
+ return isGuideOrigin(source)
33
+ }
34
+
35
+ /** The marker to stamp onto a built segment, or undefined for agent approvals.
36
+ * Keeps `origin` out of segment data unless it means something. */
37
+ export function guideApprovalOrigin(
38
+ source: OriginBearing | null | undefined,
39
+ ): GuideOrigin | undefined {
40
+ return isGuideApproval(source) ? GUIDE_ORIGIN : undefined
41
+ }
42
+
43
+ /**
44
+ * Does this approval render inline in its turn (true), or take the consumer's
45
+ * escalation treatment (false)?
46
+ *
47
+ * `displayApprovalTypes` undefined means "no tier gate configured" and every
48
+ * agent approval displays — the pre-existing history-replay default, preserved
49
+ * here so both kernels can share one predicate.
50
+ */
51
+ export function approvalDisplaysInline(
52
+ source: OriginBearing | null | undefined,
53
+ approvalType: string,
54
+ displayApprovalTypes?: readonly string[],
55
+ ): boolean {
56
+ if (isGuideApproval(source)) return true
57
+ return !displayApprovalTypes || displayApprovalTypes.includes(approvalType)
58
+ }
59
+
60
+ /**
61
+ * True for a rendered segment that holds a Product Guide card (single or batch).
62
+ *
63
+ * Consumers use it to keep such a card in the message flow, where the hub's own
64
+ * chat puts it, instead of lifting it into their sticky pending-approvals
65
+ * footer — and to route its buttons to the hub rather than to the agent.
66
+ */
67
+ export function isGuideApprovalSegment(segment: MessageSegment): boolean {
68
+ if (segment.type !== 'approval_request' && segment.type !== 'approval_batch') return false
69
+ return isGuideApproval(segment.data)
70
+ }
@@ -14,6 +14,15 @@ export {
14
14
  type AccumulatorCallbacks,
15
15
  } from './message-segment-accumulator'
16
16
 
17
+ // Where an approval card renders — one rule for both live kernels, the history
18
+ // replay, and hosts that sort cards between their own footer and the flow.
19
+ export {
20
+ isGuideApproval,
21
+ guideApprovalOrigin,
22
+ approvalDisplaysInline,
23
+ isGuideApprovalSegment,
24
+ } from './approval-display'
25
+
17
26
  // Historical message processing
18
27
  export {
19
28
  processHistoricalMessages,
@@ -12,6 +12,7 @@ import type {
12
12
  AskOptionData,
13
13
  MessageSegment,
14
14
  ToolExecutionSegment,
15
+ ApprovalRequestField,
15
16
  ApprovalRequestSegment,
16
17
  ApprovalBatchSegment,
17
18
  ApprovalBatchExecutionState,
@@ -324,23 +325,55 @@ export class MessageSegmentAccumulator {
324
325
  command: string,
325
326
  explanation: string | undefined,
326
327
  approvalType: string,
327
- status: ChatApprovalStatus = 'pending'
328
+ status: ChatApprovalStatus = 'pending',
329
+ /** Structured label/value rows. The card prefers them over `explanation`
330
+ * (see `ApprovalRequestData.fields`). Optional because the agent's own
331
+ * approvals carry prose; a Product Guide card is almost entirely fields —
332
+ * dropping them here left it as a bare title. */
333
+ fields?: ApprovalRequestField[],
334
+ /** Where the card came from; `'guide'` keeps it inline (see
335
+ * `ApprovalRequestData.origin`). */
336
+ origin?: 'guide'
328
337
  ): MessageSegment[] {
329
- const segment: ApprovalRequestSegment = {
338
+ this.segments.push(
339
+ this.buildApprovalRequestSegment(
340
+ requestId,
341
+ { command, explanation, approvalType, fields, origin },
342
+ status,
343
+ ),
344
+ )
345
+ return this.getSegments()
346
+ }
347
+
348
+ /**
349
+ * Build one approval-request segment — THE constructor for this segment type.
350
+ *
351
+ * Three paths produce these: `addApprovalRequest` (live stream and replay),
352
+ * `flushPendingApprovals` (tracked-but-unresolved after a history replay) and
353
+ * `processApprovalResult` (a result arriving for a tracked request). They used
354
+ * to hand-write the object each, which is how `fields` and `origin` reached
355
+ * the card down one path and not the others — the same proposal rendering as a
356
+ * bare title, or losing the marker that routes its buttons to the hub.
357
+ */
358
+ private buildApprovalRequestSegment(
359
+ requestId: string,
360
+ approval: PendingApproval,
361
+ status: ChatApprovalStatus,
362
+ ): ApprovalRequestSegment {
363
+ return {
330
364
  type: 'approval_request',
331
365
  data: {
332
- command,
333
- explanation,
366
+ command: approval.command,
367
+ explanation: approval.explanation,
334
368
  requestId,
335
- approvalType,
369
+ approvalType: approval.approvalType,
370
+ ...(approval.fields && approval.fields.length > 0 ? { fields: approval.fields } : {}),
371
+ ...(approval.origin ? { origin: approval.origin } : {}),
336
372
  },
337
373
  status,
338
374
  onApprove: this.callbacks.onApprove,
339
375
  onReject: this.callbacks.onReject,
340
376
  }
341
-
342
- this.segments.push(segment)
343
- return this.getSegments()
344
377
  }
345
378
 
346
379
  /**
@@ -363,6 +396,9 @@ export class MessageSegmentAccumulator {
363
396
  status: ChatApprovalStatus = 'pending',
364
397
  executions?: Record<string, ApprovalBatchExecutionState>,
365
398
  resolvedByName?: string | null,
399
+ /** Where the batch came from; `'guide'` keeps it inline and routes it to
400
+ * the hub, exactly as for a single card (see `ApprovalBatchData.origin`). */
401
+ origin?: 'guide',
366
402
  ): MessageSegment[] {
367
403
  const existingIndex = this.segments.findIndex(
368
404
  (s): s is ApprovalBatchSegment =>
@@ -372,6 +408,9 @@ export class MessageSegmentAccumulator {
372
408
  if (existingIndex !== -1) {
373
409
  const existing = this.segments[existingIndex] as ApprovalBatchSegment
374
410
  const mergedExecutions = executions ?? existing.data.executions
411
+ // An upsert must not strip the marker: the flip to `approved` comes from
412
+ // a later event that carries no origin of its own.
413
+ const mergedOrigin = origin ?? existing.data.origin
375
414
  this.segments[existingIndex] = {
376
415
  ...existing,
377
416
  data: {
@@ -379,6 +418,7 @@ export class MessageSegmentAccumulator {
379
418
  approvalType,
380
419
  toolCalls,
381
420
  ...(mergedExecutions ? { executions: mergedExecutions } : {}),
421
+ ...(mergedOrigin ? { origin: mergedOrigin } : {}),
382
422
  },
383
423
  status,
384
424
  resolvedByName: resolvedByName ?? existing.resolvedByName,
@@ -395,6 +435,7 @@ export class MessageSegmentAccumulator {
395
435
  approvalType,
396
436
  toolCalls,
397
437
  ...(executions ? { executions } : {}),
438
+ ...(origin ? { origin } : {}),
398
439
  },
399
440
  status,
400
441
  resolvedByName,
@@ -417,20 +458,17 @@ export class MessageSegmentAccumulator {
417
458
  ): { segment: ApprovalRequestSegment; pendingData: PendingApproval | null } | null {
418
459
  const pendingApproval = this.pendingApprovals.get(requestId)
419
460
  const status: ChatApprovalStatus = approved ? 'approved' : 'rejected'
420
-
421
- const segment: ApprovalRequestSegment = {
422
- type: 'approval_request',
423
- data: {
461
+
462
+ const segment = this.buildApprovalRequestSegment(
463
+ requestId,
464
+ {
465
+ ...pendingApproval,
424
466
  command: pendingApproval?.command || '',
425
- explanation: pendingApproval?.explanation,
426
- requestId,
427
467
  approvalType: pendingApproval?.approvalType || approvalType,
428
468
  },
429
469
  status,
430
- onApprove: this.callbacks.onApprove,
431
- onReject: this.callbacks.onReject,
432
- }
433
-
470
+ )
471
+
434
472
  this.segments.push(segment)
435
473
 
436
474
  if (pendingApproval) {
@@ -538,22 +576,11 @@ export class MessageSegmentAccumulator {
538
576
  */
539
577
  flushPendingApprovals(): ApprovalRequestSegment[] {
540
578
  const segments: ApprovalRequestSegment[] = []
541
-
579
+
542
580
  this.pendingApprovals.forEach((approval, requestId) => {
543
- segments.push({
544
- type: 'approval_request',
545
- data: {
546
- command: approval.command,
547
- explanation: approval.explanation,
548
- requestId,
549
- approvalType: approval.approvalType,
550
- },
551
- status: 'pending',
552
- onApprove: this.callbacks.onApprove,
553
- onReject: this.callbacks.onReject,
554
- })
581
+ segments.push(this.buildApprovalRequestSegment(requestId, approval, 'pending'))
555
582
  })
556
-
583
+
557
584
  return segments
558
585
  }
559
586
 
@@ -627,6 +654,8 @@ export class MessageSegmentAccumulator {
627
654
  data.explanation,
628
655
  data.approvalType || '',
629
656
  status,
657
+ data.fields,
658
+ data.origin,
630
659
  )
631
660
  break
632
661
  }
@@ -639,6 +668,7 @@ export class MessageSegmentAccumulator {
639
668
  status,
640
669
  data.executions,
641
670
  resolvedByName,
671
+ data.origin,
642
672
  )
643
673
  break
644
674
  }
@@ -40,7 +40,8 @@ import {
40
40
  import { ESCALATION_STATE, escalationResolvedStatus, type ChatStreamEvent } from '../../../chat-protocol/events'
41
41
  // One normalizer for ask rows, shared with the live decoder — history and the
42
42
  // stream must agree on which options are usable.
43
- import { normalizeAskOptions } from '../../../chat-protocol/nats-decoder'
43
+ import { guideFrameEvent, normalizeAskOptions } from '../../../chat-protocol/nats-decoder'
44
+ import { approvalDisplaysInline, guideApprovalOrigin } from './approval-display'
44
45
  import { MessageSegmentAccumulator, createMessageSegmentAccumulator } from './message-segment-accumulator'
45
46
  import { applyApprovalStatusToSegment } from '../stream/message-mutations'
46
47
  import { getCommandText } from './tool-call-helpers'
@@ -115,11 +116,21 @@ export function decodeHistoricalMessageData(data: MessageData): ChatStreamEvent
115
116
  }
116
117
  return null
117
118
 
118
- case MESSAGE_TYPE.GUIDE:
119
+ // Two persisted shapes, mirroring the live `GUIDE` chunk: the answer body
120
+ // (`text`) and a Product Guide frame the agent re-streamed (`payload`,
121
+ // persisted so a card survives a reload). Decoded through the SAME
122
+ // `guideFrameEvent` the live path uses — a second mapping here would let
123
+ // history and realtime disagree about the same bytes.
124
+ case MESSAGE_TYPE.GUIDE: {
119
125
  if ('text' in data && data.text) {
120
126
  return { type: 'guide-delta', text: data.text }
121
127
  }
128
+ const payload = 'payload' in data ? data.payload : undefined
129
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
130
+ return guideFrameEvent(payload as Record<string, unknown>)
131
+ }
122
132
  return null
133
+ }
123
134
 
124
135
  // Same completeness gate as the live decoder (`decodeNatsChunk`): a
125
136
  // persisted row without a question or without options is not a card the
@@ -361,12 +372,24 @@ function applyHistoryEvent(
361
372
  const approvalType = event.approvalType || 'CLIENT'
362
373
  const toolCalls = event.toolCalls as PendingToolCallData[] | undefined
363
374
  const isBatch = !!toolCalls && toolCalls.length > 0
375
+ // Same rule the live kernels use — a card must not change where it
376
+ // renders (or which backend its buttons hit) just because the page was
377
+ // reloaded and it came back through history instead of the stream.
378
+ const guideOrigin = guideApprovalOrigin(event)
364
379
 
365
- if (!displayApprovalTypes || displayApprovalTypes.includes(approvalType)) {
380
+ if (approvalDisplaysInline(event, approvalType, displayApprovalTypes)) {
366
381
  if (isBatch) {
367
382
  const status = (approvalStatuses[event.requestId] as ChatApprovalStatus) || 'pending'
368
383
  if (batchApprovalsEnabled) {
369
- accumulator.addApprovalBatch(event.requestId, approvalType, toolCalls!, status)
384
+ accumulator.addApprovalBatch(
385
+ event.requestId,
386
+ approvalType,
387
+ toolCalls!,
388
+ status,
389
+ undefined,
390
+ undefined,
391
+ guideOrigin,
392
+ )
370
393
  } else {
371
394
  // Flag OFF — unfold batch into N legacy approval cards (same id).
372
395
  for (const call of toolCalls!) {
@@ -377,6 +400,8 @@ function applyHistoryEvent(
377
400
  call.toolExplanation,
378
401
  approvalType,
379
402
  status,
403
+ undefined,
404
+ guideOrigin,
380
405
  )
381
406
  }
382
407
  }
@@ -389,19 +414,28 @@ function applyHistoryEvent(
389
414
  // sticky card on every history re-process. Mirror the batch path
390
415
  // and honor `approvalStatuses`.
391
416
  const resolvedStatus = approvalStatuses[event.requestId] as ChatApprovalStatus | undefined
392
- if (resolvedStatus === 'approved' || resolvedStatus === 'rejected') {
417
+ const isResolved = resolvedStatus === 'approved' || resolvedStatus === 'rejected'
418
+ // A guide card is added inline even while pending. The tracked path
419
+ // below ends in `flushPendingApprovals`, whose segments the consumer
420
+ // lifts into a sticky footer — that is the treatment for the
421
+ // consumer's OWN approvals, and it is not how the hub's chat renders
422
+ // a proposal (nor where its preamble expects it).
423
+ if (guideOrigin || isResolved) {
393
424
  accumulator.addApprovalRequest(
394
425
  event.requestId,
395
426
  event.command || '',
396
427
  event.explanation,
397
428
  approvalType,
398
- resolvedStatus,
429
+ resolvedStatus ?? 'pending',
430
+ event.fields,
431
+ guideOrigin,
399
432
  )
400
433
  } else {
401
434
  accumulator.trackApprovalRequest(event.requestId, {
402
435
  command: event.command || '',
403
436
  explanation: event.explanation,
404
437
  approvalType,
438
+ fields: event.fields,
405
439
  })
406
440
  }
407
441
  }