@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.532",
3
+ "version": "0.0.533",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -263,6 +263,76 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
263
263
  "text": "",
264
264
  "type": "guide-delta",
265
265
  },
266
+ "guide_frame_approval_batch": {
267
+ "approvalType": "chat",
268
+ "origin": "guide",
269
+ "requestId": "batch:prop-1",
270
+ "status": "pending",
271
+ "toolCalls": [
272
+ {
273
+ "requiresApproval": true,
274
+ "toolCallArguments": null,
275
+ "toolExecutionRequestId": "prop-1",
276
+ "toolName": "create_ticket",
277
+ "toolTitle": "Open a ticket",
278
+ },
279
+ ],
280
+ "type": "approval-request",
281
+ },
282
+ "guide_frame_approval_request": {
283
+ "approvalType": "create_ticket",
284
+ "command": "Open a support ticket",
285
+ "fields": [
286
+ {
287
+ "label": "Subject",
288
+ "value": "Agent will not enroll",
289
+ },
290
+ ],
291
+ "origin": "guide",
292
+ "requestId": "prop-1",
293
+ "status": "pending",
294
+ "type": "approval-request",
295
+ },
296
+ "guide_frame_decision_resolved": {
297
+ "ok": true,
298
+ "origin": "guide",
299
+ "requestId": "prop-1",
300
+ "status": "approved",
301
+ "type": "approval-resolved",
302
+ "willAutoContinue": false,
303
+ },
304
+ "guide_frame_metadata": {
305
+ "conversationId": "conv-1",
306
+ "origin": "guide",
307
+ "type": "metadata",
308
+ },
309
+ "guide_frame_payload_array": null,
310
+ "guide_frame_payload_empty": null,
311
+ "guide_frame_payload_not_object": null,
312
+ "guide_frame_routing": null,
313
+ "guide_frame_status_thinking": null,
314
+ "guide_frame_text_leading": {
315
+ "text": "I'll look that up in the docs.",
316
+ "type": "guide-delta",
317
+ },
318
+ "guide_frame_text_wins": {
319
+ "text": "body",
320
+ "type": "guide-delta",
321
+ },
322
+ "guide_frame_thinking": {
323
+ "text": "The user asked about <policies> setup",
324
+ "type": "thinking-delta",
325
+ },
326
+ "guide_frame_tool_error": {
327
+ "title": "Could not reach HubSpot",
328
+ "type": "error",
329
+ },
330
+ "guide_frame_usage_start": null,
331
+ "guide_frame_with_seq": {
332
+ "seq": 77,
333
+ "text": "Checking the docs…",
334
+ "type": "guide-delta",
335
+ },
266
336
  "guide_missing_text": null,
267
337
  "message_end": {
268
338
  "type": "turn-end",
@@ -11,7 +11,8 @@
11
11
  */
12
12
 
13
13
  import { describe, it, expect } from 'vitest'
14
- import { decodeNatsChunk } from '../nats-decoder'
14
+ import type { ChatStreamEvent } from '../events'
15
+ import { decodeNatsChunk, guideEventForNats } from '../nats-decoder'
15
16
 
16
17
  /** Recorded corpus of realistic NATS chunk shapes, keyed by scenario name. */
17
18
  const CORPUS: Record<string, unknown> = {
@@ -29,6 +30,69 @@ const CORPUS: Record<string, unknown> = {
29
30
  guide_empty_string: { type: 'GUIDE', text: '' },
30
31
  guide_missing_text: { type: 'GUIDE' },
31
32
 
33
+ // GUIDE frames — hub frames the agent re-streams verbatim inside the same
34
+ // chunk type, under `payload`. They cross over by DEFAULT; only the dialog's
35
+ // own accounting and phase stop at the seam. See `guideEventForNats`.
36
+ guide_frame_text_leading: {
37
+ type: 'GUIDE',
38
+ payload: { kind: 'text-leading', text: "I'll look that up in the docs." },
39
+ },
40
+ guide_frame_thinking: {
41
+ type: 'GUIDE',
42
+ payload: { kind: 'thinking-delta', text: 'The user asked about <policies> setup' },
43
+ },
44
+ guide_frame_tool_error: {
45
+ type: 'GUIDE',
46
+ payload: { kind: 'tool_error', message: 'Could not reach HubSpot' },
47
+ },
48
+ // Dropped: would blank the model badge / relabel the turn with the hub's model.
49
+ guide_frame_routing: {
50
+ type: 'GUIDE',
51
+ payload: { kind: 'routing', routedComplexity: 'deep', routedModel: 'claude-x', routedThinkingBudget: 8000 },
52
+ },
53
+ guide_frame_metadata: {
54
+ type: 'GUIDE',
55
+ payload: { model: 'claude-x', modelLabel: 'Guide model', conversationId: 'conv-1' },
56
+ },
57
+ // Dropped: the dialog owns its own phase and token accounting.
58
+ guide_frame_status_thinking: { type: 'GUIDE', payload: { status: 'thinking' } },
59
+ guide_frame_usage_start: { type: 'GUIDE', payload: { kind: 'usage', stage: 'start', input_tokens: 12 } },
60
+ // Crosses over stamped `origin: 'guide'` — the marker that keeps the card
61
+ // inline and sends its buttons to the hub's confirm route.
62
+ guide_frame_approval_request: {
63
+ type: 'GUIDE',
64
+ payload: {
65
+ kind: 'approval_request',
66
+ proposalId: 'prop-1',
67
+ toolName: 'create_ticket',
68
+ title: 'Open a support ticket',
69
+ fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
70
+ },
71
+ },
72
+ guide_frame_approval_batch: {
73
+ type: 'GUIDE',
74
+ payload: {
75
+ kind: 'approval_batch',
76
+ batchId: 'batch:prop-1',
77
+ proposals: [{ proposalId: 'prop-1', toolName: 'create_ticket', title: 'Open a ticket' }],
78
+ },
79
+ },
80
+ guide_frame_decision_resolved: {
81
+ type: 'GUIDE',
82
+ payload: { kind: 'decision_resolved', proposalId: 'prop-1', ok: true, action: 'approved', willAutoContinue: false },
83
+ },
84
+ // Malformed payloads degrade to a no-op, like every other chunk shape.
85
+ guide_frame_payload_not_object: { type: 'GUIDE', payload: 'text-leading' },
86
+ guide_frame_payload_array: { type: 'GUIDE', payload: [{ kind: 'text-leading', text: 'hi' }] },
87
+ guide_frame_payload_empty: { type: 'GUIDE', payload: {} },
88
+ // `text` wins when both are present — the body is what the agent persists.
89
+ guide_frame_text_wins: { type: 'GUIDE', text: 'body', payload: { kind: 'thinking-delta', text: 'ignored' } },
90
+ guide_frame_with_seq: {
91
+ type: 'GUIDE',
92
+ streamSeq: 77,
93
+ payload: { kind: 'text-leading', text: 'Checking the docs…' },
94
+ },
95
+
32
96
  // ASK — the guide-routing clarification card. `text` is the intro sentence
33
97
  // riding the same chunk; a card without a question or without usable options
34
98
  // is dropped (nothing the user could answer).
@@ -280,6 +344,49 @@ describe('decodeNatsChunk — golden corpus', () => {
280
344
  })
281
345
  })
282
346
 
347
+ describe('guideEventForNats — the two kernels reconciled', () => {
348
+ // The property that matters for maintenance: `leading-frames` is the ONE
349
+ // place a hub frame kind is taught to the client, so an event kind that
350
+ // reaches this adapter must cross over WITHOUT an edit here. These use event
351
+ // types the frame table cannot produce today on purpose — they stand in for
352
+ // whatever the hub ships next.
353
+ it('passes an unfamiliar event through unchanged', () => {
354
+ const ask: ChatStreamEvent = {
355
+ type: 'ask',
356
+ question: 'Which workspace?',
357
+ options: [{ label: 'Acme' }],
358
+ }
359
+ expect(guideEventForNats(ask)).toEqual(ask)
360
+ })
361
+
362
+ it('stamps origin on the events that carry it, so the card routes to the hub', () => {
363
+ expect(
364
+ guideEventForNats({ type: 'approval-request', requestId: 'prop-1', approvalType: 'create_ticket' }),
365
+ ).toMatchObject({ approvalType: 'create_ticket', origin: 'guide' })
366
+ expect(guideEventForNats({ type: 'approval-resolved', requestId: 'prop-1', status: 'approved' })).toMatchObject({
367
+ origin: 'guide',
368
+ })
369
+ })
370
+
371
+ it('stops the events the agent owns for this dialog', () => {
372
+ expect(guideEventForNats({ type: 'usage', stage: 'start', input_tokens: 10 })).toBeNull()
373
+ expect(guideEventForNats({ type: 'status', phase: 'thinking' })).toBeNull()
374
+ expect(
375
+ guideEventForNats({ type: 'token-usage', inputTokensSize: 1, outputTokensSize: 2, totalTokensSize: 3, contextSize: 4 }),
376
+ ).toBeNull()
377
+ expect(guideEventForNats({ type: 'dialog-closed' })).toBeNull()
378
+ })
379
+
380
+ it('keeps metadata only for the hub conversation id every confirm must quote back', () => {
381
+ expect(guideEventForNats({ type: 'metadata', conversationId: 'conv-1', modelName: 'hub-model' })).toEqual({
382
+ type: 'metadata',
383
+ conversationId: 'conv-1',
384
+ origin: 'guide',
385
+ })
386
+ expect(guideEventForNats({ type: 'metadata', modelName: 'hub-model' })).toBeNull()
387
+ })
388
+ })
389
+
283
390
  describe('decodeNatsChunk — seq envelope', () => {
284
391
  it('lifts a numeric JetStream `streamSeq` into `seq`, and omits it otherwise (snapshot)', () => {
285
392
  expect({
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The hub's confirm-tool contract — request shape and error envelope.
3
+ *
4
+ * Resolving a Product Guide proposal is a POST to `endpoints.approvalToolUrl`
5
+ * whose response is an ordinary guide stream (a `decision_resolved` frame, then
6
+ * the hub's auto-continuation). Two callers make that call today: the SSE chat
7
+ * adapter, and any host replaying the same route into a different transport's
8
+ * conversation (the product app resolves guide cards inside a NATS Mingo
9
+ * dialog). They MUST send byte-identical bodies — a proposal is single-use, so a
10
+ * caller that drifts on a field name does not degrade, it fails the write — and
11
+ * they must surface the same server copy, because the hub's errors are written
12
+ * for the end user ("This approval expired — ask again to get a fresh one").
13
+ *
14
+ * Hence both live here rather than at either call site.
15
+ *
16
+ * Server-safe: no React, no browser APIs.
17
+ */
18
+
19
+ /** What the user did with the card. */
20
+ export type ApprovalToolAction = 'approve' | 'reject'
21
+
22
+ /** Client-side shape of a confirm; `buildConfirmToolBody` renames it to the
23
+ * wire's own casing so no caller hand-writes `proposal_id`. */
24
+ export interface ConfirmToolRequest {
25
+ /** The hub-minted proposal id carried by the card. */
26
+ proposalId: string
27
+ action: ApprovalToolAction
28
+ /**
29
+ * The hub's conversation id, quoted back verbatim. The hub rejects a confirm
30
+ * without it: a proposal only means something inside the conversation that
31
+ * minted it.
32
+ */
33
+ conversationId: string | null | undefined
34
+ }
35
+
36
+ /** Build the confirm-tool request body. The ONE place that knows the wire
37
+ * spells the id `proposal_id` while everything else is camelCase. */
38
+ export function buildConfirmToolBody(request: ConfirmToolRequest): Record<string, unknown> {
39
+ return {
40
+ proposal_id: request.proposalId,
41
+ action: request.action,
42
+ conversationId: request.conversationId,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Pull the user-facing copy out of a failed chat/confirm response.
48
+ *
49
+ * The route-base envelope is `{error, code}`; `error` is written for the end
50
+ * user, so it beats any status-code copy the client could invent. Returns null
51
+ * when the body is not that envelope (HTML error page, empty body, gateway
52
+ * response) — the caller then supplies its own generic line.
53
+ *
54
+ * Consumes the body: call it once, on a response you have already decided is a
55
+ * failure.
56
+ */
57
+ export async function readServerErrorMessage(response: {
58
+ json: () => Promise<unknown>
59
+ }): Promise<string | null> {
60
+ try {
61
+ const body = (await response.json()) as { error?: unknown } | null
62
+ if (typeof body?.error === 'string' && body.error.length > 0) return body.error
63
+ } catch {
64
+ /* non-JSON error body — the caller falls through to its generic copy */
65
+ }
66
+ return null
67
+ }
@@ -35,6 +35,9 @@
35
35
 
36
36
  import { FRAME_TERMINATOR, END_OF_LEADING, TRAILER_SENTINEL } from './frames'
37
37
  import type { ChatStreamEvent } from './events'
38
+ // The frame table is SHARED with the NATS decoder — guide answers re-stream the
39
+ // hub's frames verbatim inside `GUIDE` chunks — so it lives in its own module.
40
+ import { mapLeadingFrame } from './leading-frames'
38
41
 
39
42
  export interface SseFrameDecoder {
40
43
  /** Feed raw response bytes; returns the events they produced. */
@@ -49,159 +52,10 @@ export interface SseFrameDecoder {
49
52
  end(): ChatStreamEvent[]
50
53
  }
51
54
 
52
- /**
53
- * Escape `<` so markdown renderers that pass HTML through (rehypeRaw)
54
- * don't treat XML-like tokens in Claude's thinking output as elements.
55
- * `<` `&lt;` preserves the visible character without breaking
56
- * blockquote `>` markers. Per-character, so it distributes over
57
- * concatenation: escape(a + b) === escape(a) + escape(b) — callers may
58
- * apply it per-delta or on the accumulated string interchangeably.
59
- */
60
- export function escapeThinkingTags(text: string): string {
61
- return text.replace(/</g, '&lt;')
62
- }
63
-
64
- /**
65
- * Map one parsed leading frame to normalized events, replicating the
66
- * legacy parser's else-if chain ORDER and its exact truthiness/typeof
67
- * gates. Frames that matched a branch but failed its inner validation
68
- * (e.g. `routing` without a string `routedComplexity`) produce NO event,
69
- * exactly like the legacy no-op.
70
- */
71
- function mapLeadingFrame(meta: any, out: ChatStreamEvent[]): void {
72
- if (meta.status === 'thinking') {
73
- out.push({ type: 'status', phase: 'thinking' })
74
- } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
75
- // Wire is ALREADY delta — emit verbatim, append-only contract.
76
- out.push({ type: 'thinking-delta', text: meta.text })
77
- } else if (meta.kind === 'usage' && meta.stage === 'start') {
78
- out.push({
79
- type: 'usage',
80
- stage: 'start',
81
- input_tokens: meta.input_tokens,
82
- cache_read_input_tokens: meta.cache_read_input_tokens,
83
- cache_creation_input_tokens: meta.cache_creation_input_tokens,
84
- })
85
- } else if (meta.kind === 'decision_resolved' && typeof meta.action === 'string') {
86
- const status = meta.action === 'rejected' ? 'rejected' : 'approved'
87
- const toolName = typeof meta.tool_name === 'string' ? meta.tool_name : undefined
88
- const result = meta.result ?? null
89
- const card = meta.card ?? null
90
- out.push({
91
- type: 'approval-resolved',
92
- status,
93
- ok: meta.ok === true,
94
- willAutoContinue: meta.willAutoContinue === true,
95
- ...(toolName ? { toolName } : {}),
96
- ...(result ? { result } : {}),
97
- ...(card?.marker ? { marker: card.marker } : {}),
98
- ...(card?.ref ? { cardRef: card.ref } : {}),
99
- ...(card?.type ? { cardType: card.type } : {}),
100
- ...(typeof meta.receiptText === 'string' ? { receiptText: meta.receiptText } : {}),
101
- requestId: typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
102
- })
103
- } else if (meta.kind === 'approval_batch' && meta.batchId && Array.isArray(meta.proposals)) {
104
- // Server-grouped multi-proposal turn → ONE batch event carrying a
105
- // tool-call row per proposal. `toolExecutionRequestId` is the row's
106
- // PROPOSAL id (each row resolves through its own per-proposal
107
- // confirm); the batch's `requestId` is the stable anchor the shell
108
- // uses for status flips. Field rows ride as the row's expandable
109
- // args so per-proposal detail stays reachable inside the batch.
110
- const toolCalls = (meta.proposals as Array<Record<string, any>>)
111
- .filter((p) => p && typeof p.proposalId === 'string')
112
- .map((p) => {
113
- const rawFields = Array.isArray(p.fields)
114
- ? (p.fields as Array<{ label?: string; value?: string }>).filter(
115
- (f) => f && f.label && f.value,
116
- )
117
- : []
118
- // Prefer human-readable identity labels for the row's
119
- // disambiguator; opaque-id labels ("Task", "Ticket") only as
120
- // the last-resort first field.
121
- const detail =
122
- rawFields.find((f) => /^(title|subject|name)$/i.test(String(f.label))) ??
123
- rawFields[0]
124
- const base =
125
- typeof p.title === 'string' && p.title.length > 0 ? p.title : String(p.toolName ?? 'Tool call')
126
- return {
127
- toolExecutionRequestId: String(p.proposalId),
128
- toolName: String(p.toolName ?? 'tool'),
129
- toolTitle: detail ? `${base} — ${detail.value}` : base,
130
- requiresApproval: true,
131
- toolCallArguments:
132
- rawFields.length > 0
133
- ? Object.fromEntries(rawFields.map((f) => [String(f.label), String(f.value)]))
134
- : null,
135
- }
136
- })
137
- if (toolCalls.length > 0) {
138
- out.push({
139
- type: 'approval-request',
140
- requestId: String(meta.batchId),
141
- approvalType: 'chat',
142
- toolCalls,
143
- status: 'pending',
144
- })
145
- }
146
- } else if (meta.kind === 'approval_request' && meta.proposalId) {
147
- const proposalId = String(meta.proposalId)
148
- const toolName = String(meta.toolName ?? 'tool')
149
- const headline =
150
- typeof meta.title === 'string' && meta.title.length > 0 ? meta.title : toolName
151
- const rawFields = Array.isArray(meta.fields)
152
- ? (meta.fields as Array<{ label?: string; value?: string }>)
153
- : []
154
- const fields: Array<{ label: string; value: string }> = []
155
- for (const f of rawFields) {
156
- if (!f || !f.label || !f.value) continue
157
- fields.push({ label: f.label, value: f.value })
158
- }
159
- out.push({
160
- type: 'approval-request',
161
- requestId: proposalId,
162
- approvalType: toolName,
163
- command: headline,
164
- fields,
165
- status: 'pending',
166
- })
167
- } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
168
- out.push({ type: 'text-delta', text: meta.text, leading: true })
169
- } else if (meta.kind === 'tool_error') {
170
- const msg =
171
- typeof meta.message === 'string' && meta.message.length > 0
172
- ? meta.message
173
- : 'Could not complete the requested action right now.'
174
- out.push({ type: 'error', title: msg })
175
- } else if (meta.kind === 'routing') {
176
- if (typeof meta.routedComplexity === 'string') {
177
- out.push({
178
- type: 'metadata',
179
- routing: {
180
- routedComplexity: meta.routedComplexity,
181
- ...(typeof meta.routedModel === 'string' ? { routedModel: meta.routedModel } : {}),
182
- routedThinkingBudget:
183
- typeof meta.routedThinkingBudget === 'number' ? meta.routedThinkingBudget : null,
184
- },
185
- })
186
- }
187
- } else {
188
- // Catch-all metadata-ish frame. Raw values pass through UNVALIDATED
189
- // (possibly undefined) so the consumer can replicate the legacy
190
- // presence/truthiness gates exactly — including the `model`-presence
191
- // trigger whose value is never stored.
192
- out.push({
193
- type: 'metadata',
194
- sources: meta.sources,
195
- refs: meta.refs,
196
- provider: meta.provider,
197
- modelLabel: meta.modelLabel,
198
- modelName: meta.model,
199
- contextWindowMaxTokens: meta.contextWindowMaxTokens,
200
- scrollAnchor: meta.scrollAnchor,
201
- conversationId: meta.conversationId,
202
- })
203
- }
204
- }
55
+ // `escapeThinkingTags` moved to `./leading-frames` alongside the frame table,
56
+ // and is re-exported here: consumers have imported it from this module since it
57
+ // was written, and the `chat-protocol` barrel re-exports it from here.
58
+ export { escapeThinkingTags } from './leading-frames'
205
59
 
206
60
  export function createSseFrameDecoder(): SseFrameDecoder {
207
61
  const textDecoder = new TextDecoder()
@@ -138,6 +138,37 @@ export interface ApprovalRequestEvent extends ChatStreamEventBase {
138
138
  fields?: ApprovalRequestField[]
139
139
  toolCalls?: ApprovalToolCall[]
140
140
  status?: 'pending'
141
+ /** Set when the card came from a Product Guide frame — see {@link GuideOrigin}. */
142
+ origin?: GuideOrigin
143
+ }
144
+
145
+ /**
146
+ * Marks an event whose payload is a Product Guide frame, whatever transport
147
+ * carried it. It exists because ONE stream can now mix both worlds: the agent
148
+ * re-streams the hub's frames into a NATS dialog, so a card typed the hub's way
149
+ * (`approvalType` = the tool name, resolved through the hub's confirm route)
150
+ * travels beside cards typed the agent's way (`approvalType` = an approval TIER
151
+ * routed to human escalation).
152
+ *
153
+ * Consumers read it to keep the guide half behaving exactly as it does in the
154
+ * hub's own chat — NOT to give it special treatment. Without it the NATS kernel
155
+ * would have to guess from `approvalType`, and every tool the hub adds would
156
+ * silently fall into the escalation path.
157
+ */
158
+ export type GuideOrigin = 'guide'
159
+
160
+ /** The only value of {@link GuideOrigin}. Lives beside the type, and beside the
161
+ * predicate below, because both decoders and every consumer that branches on
162
+ * provenance must compare against the same token — a bare `'guide'` literal
163
+ * typo silently disables the branch instead of failing to compile. */
164
+ export const GUIDE_ORIGIN: GuideOrigin = 'guide'
165
+
166
+ /** True for anything stamped as coming from the Product Guide — a stream event
167
+ * or the `data` of a segment built from one. */
168
+ export function isGuideOrigin(
169
+ source: { origin?: GuideOrigin | string } | null | undefined,
170
+ ): boolean {
171
+ return source?.origin === GUIDE_ORIGIN
141
172
  }
142
173
 
143
174
  /** An approval request was resolved (SSE `decision_resolved` frame /
@@ -158,6 +189,8 @@ export interface ApprovalResolvedEvent extends ChatStreamEventBase {
158
189
  marker?: string
159
190
  result?: DecisionResolvedFrame['result']
160
191
  willAutoContinue?: boolean
192
+ /** Set when the resolution came from a Product Guide frame — see {@link GuideOrigin}. */
193
+ origin?: GuideOrigin
161
194
  }
162
195
 
163
196
  /** The client is offered a handoff of this ticket to a human technician
@@ -245,6 +278,13 @@ export interface ChatMetadataEvent extends ChatStreamEventBase {
245
278
  routedModel?: string
246
279
  routedThinkingBudget: number | null
247
280
  }
281
+ /**
282
+ * Set when the metadata came from a Product Guide frame — see
283
+ * {@link GuideOrigin}. Such an event carries ONLY `conversationId`: it exists
284
+ * to record the hub's conversation id (every confirm-tool call must quote it
285
+ * back), NOT to describe the dialog's model, which stays the agent's.
286
+ */
287
+ origin?: GuideOrigin
248
288
  }
249
289
 
250
290
  /** SSE usage frames — raw wire keys (snake_case) preserved. */
@@ -14,6 +14,9 @@ export * from './events'
14
14
  export * from './encode'
15
15
  export * from './decode'
16
16
  export * from './nats-decoder'
17
+ // The hub's confirm-tool request/error contract — shared by every transport
18
+ // that resolves a Product Guide proposal.
19
+ export * from './confirm-tool'
17
20
  // Cross-repo IP bucket-key normalizer (producer app + consumer hub share it).
18
21
  export * from './ip-normalize'
19
22
  // Cross-repo env-flag predicate — the trust assertion that gates the IP