@flamingo-stack/openframe-frontend-core 0.0.523-1663.4375.1 → 0.0.523-1680.4386.1
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/events.d.ts +46 -1
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +57 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +56 -1
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-NMZ33YWL.js → chunk-2ALPHSGU.js} +4 -4
- package/dist/{chunk-KBE7SNFZ.cjs → chunk-2MPS2QY5.cjs} +5 -5
- package/dist/{chunk-KBE7SNFZ.cjs.map → chunk-2MPS2QY5.cjs.map} +1 -1
- package/dist/{chunk-LJCBSJFU.cjs → chunk-3OFRFRBW.cjs} +26 -26
- package/dist/{chunk-LJCBSJFU.cjs.map → chunk-3OFRFRBW.cjs.map} +1 -1
- package/dist/{chunk-7EYNWY2N.js → chunk-43MPUCDL.js} +2 -2
- package/dist/{chunk-NBANPQ3N.js → chunk-4DJL34HL.js} +2 -2
- package/dist/{chunk-PZVYYDFL.cjs → chunk-6MW6ELZQ.cjs} +37 -37
- package/dist/{chunk-PZVYYDFL.cjs.map → chunk-6MW6ELZQ.cjs.map} +1 -1
- package/dist/{chunk-H5TAFA4H.cjs → chunk-ARCH6RK6.cjs} +4 -4
- package/dist/{chunk-H5TAFA4H.cjs.map → chunk-ARCH6RK6.cjs.map} +1 -1
- package/dist/{chunk-L4DKYRHG.js → chunk-C7T363HO.js} +2 -2
- package/dist/{chunk-OPU5BLBT.js → chunk-CBD3IHPF.js} +2 -2
- package/dist/{chunk-HPGBHELQ.cjs → chunk-CE4CBCKX.cjs} +7 -7
- package/dist/{chunk-HPGBHELQ.cjs.map → chunk-CE4CBCKX.cjs.map} +1 -1
- package/dist/{chunk-7LSK5YFW.js → chunk-E2W2XMX2.js} +2 -2
- package/dist/{chunk-VYRP7JOD.cjs → chunk-HM53VV7K.cjs} +1245 -923
- package/dist/chunk-HM53VV7K.cjs.map +1 -0
- package/dist/{chunk-QBLLF7VS.js → chunk-HYVYAWUO.js} +2 -2
- package/dist/{chunk-YJLQQGLH.js → chunk-INTFGXHO.js} +5625 -5303
- package/dist/chunk-INTFGXHO.js.map +1 -0
- package/dist/{chunk-43QX3P4G.cjs → chunk-JBCNOJMX.cjs} +3 -3
- package/dist/{chunk-43QX3P4G.cjs.map → chunk-JBCNOJMX.cjs.map} +1 -1
- package/dist/{chunk-ZIIIYUZ5.cjs → chunk-KSZPPVXT.cjs} +11 -11
- package/dist/{chunk-ZIIIYUZ5.cjs.map → chunk-KSZPPVXT.cjs.map} +1 -1
- package/dist/{chunk-ICWQQQTR.js → chunk-LEMU4JMX.js} +2 -2
- package/dist/{chunk-OGXZCZSA.cjs → chunk-M7FGOI4V.cjs} +31 -31
- package/dist/{chunk-OGXZCZSA.cjs.map → chunk-M7FGOI4V.cjs.map} +1 -1
- package/dist/{chunk-FP6HEAQC.js → chunk-MVYTOP3B.js} +5 -5
- package/dist/{chunk-ANKQQTAI.js → chunk-MYQAF43S.js} +4 -4
- package/dist/{chunk-J2OPY22M.cjs → chunk-OPPGJ4PY.cjs} +87 -87
- package/dist/{chunk-J2OPY22M.cjs.map → chunk-OPPGJ4PY.cjs.map} +1 -1
- package/dist/{chunk-7ZIGB34Y.js → chunk-QCWVGCT3.js} +6 -6
- package/dist/{chunk-JGYVXVN6.cjs → chunk-QRYJYZTW.cjs} +61 -61
- package/dist/{chunk-JGYVXVN6.cjs.map → chunk-QRYJYZTW.cjs.map} +1 -1
- package/dist/{chunk-JKUYXCG2.cjs → chunk-S4BAM7EC.cjs} +9 -9
- package/dist/{chunk-JKUYXCG2.cjs.map → chunk-S4BAM7EC.cjs.map} +1 -1
- package/dist/{chunk-7RIYOJLG.cjs → chunk-TH4BQCOD.cjs} +26 -26
- package/dist/{chunk-7RIYOJLG.cjs.map → chunk-TH4BQCOD.cjs.map} +1 -1
- package/dist/{chunk-M45XIHZY.js → chunk-TPIF252F.js} +2 -2
- package/dist/{chunk-D3PPR2WR.cjs → chunk-YTLLRLMU.cjs} +14 -14
- package/dist/{chunk-D3PPR2WR.cjs.map → chunk-YTLLRLMU.cjs.map} +1 -1
- package/dist/{chunk-BPBMHS4U.js → chunk-ZYBBV6CK.js} +2 -2
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/escalation-offer-message.d.ts +26 -0
- package/dist/components/chat/escalation-offer-message.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +6 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +2 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +5 -1
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +1 -1
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/stream/message-mutations.d.ts +6 -2
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
- package/dist/components/chat/ticket-escalated-message.d.ts +25 -0
- package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -0
- package/dist/components/chat/types/message.types.d.ts +74 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +13 -0
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +21 -2
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/board/ticket-card.d.ts.map +1 -1
- package/dist/components/features/board/types.d.ts +7 -0
- package/dist/components/features/board/types.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +136 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +15 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +6 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +5 -1
- package/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +29 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +32 -0
- package/src/chat-protocol/events.ts +66 -0
- package/src/chat-protocol/nats-decoder.ts +47 -0
- package/src/components/chat/__tests__/escalation-offer.test.ts +276 -0
- package/src/components/chat/chat-message-enhanced.tsx +15 -0
- package/src/components/chat/chat-message-list.tsx +3 -1
- package/src/components/chat/escalation-offer-message.tsx +49 -0
- package/src/components/chat/index.ts +2 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +50 -0
- package/src/components/chat/stream/message-mutations.ts +47 -20
- package/src/components/chat/ticket-escalated-message.tsx +68 -0
- package/src/components/chat/types/message.types.ts +86 -1
- package/src/components/chat/types/processing.types.ts +13 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +10 -0
- package/src/components/chat/utils/history-merge.ts +7 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +81 -2
- package/src/components/chat/utils/process-historical-messages.ts +123 -4
- package/src/components/features/board/ticket-card.tsx +7 -1
- package/src/components/features/board/types.ts +7 -0
- package/src/stories/EscalationOfferMessage.stories.tsx +86 -0
- package/src/stories/TicketCard.stories.tsx +12 -0
- package/src/stories/TicketEscalatedMessage.stories.tsx +98 -0
- package/dist/chunk-VYRP7JOD.cjs.map +0 -1
- package/dist/chunk-YJLQQGLH.js.map +0 -1
- /package/dist/{chunk-NMZ33YWL.js.map → chunk-2ALPHSGU.js.map} +0 -0
- /package/dist/{chunk-7EYNWY2N.js.map → chunk-43MPUCDL.js.map} +0 -0
- /package/dist/{chunk-NBANPQ3N.js.map → chunk-4DJL34HL.js.map} +0 -0
- /package/dist/{chunk-L4DKYRHG.js.map → chunk-C7T363HO.js.map} +0 -0
- /package/dist/{chunk-OPU5BLBT.js.map → chunk-CBD3IHPF.js.map} +0 -0
- /package/dist/{chunk-7LSK5YFW.js.map → chunk-E2W2XMX2.js.map} +0 -0
- /package/dist/{chunk-QBLLF7VS.js.map → chunk-HYVYAWUO.js.map} +0 -0
- /package/dist/{chunk-ICWQQQTR.js.map → chunk-LEMU4JMX.js.map} +0 -0
- /package/dist/{chunk-FP6HEAQC.js.map → chunk-MVYTOP3B.js.map} +0 -0
- /package/dist/{chunk-ANKQQTAI.js.map → chunk-MYQAF43S.js.map} +0 -0
- /package/dist/{chunk-7ZIGB34Y.js.map → chunk-QCWVGCT3.js.map} +0 -0
- /package/dist/{chunk-M45XIHZY.js.map → chunk-TPIF252F.js.map} +0 -0
- /package/dist/{chunk-BPBMHS4U.js.map → chunk-ZYBBV6CK.js.map} +0 -0
package/package.json
CHANGED
|
@@ -179,6 +179,26 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
|
|
|
179
179
|
"title": "Agent crashed",
|
|
180
180
|
"type": "error",
|
|
181
181
|
},
|
|
182
|
+
"escalation_offer_approved": {
|
|
183
|
+
"offerId": "offer-1",
|
|
184
|
+
"resolvedByName": "John Smith",
|
|
185
|
+
"status": "approved",
|
|
186
|
+
"type": "escalation-offer-resolved",
|
|
187
|
+
},
|
|
188
|
+
"escalation_offer_missing_id": null,
|
|
189
|
+
"escalation_offer_pending": {
|
|
190
|
+
"offerId": "offer-1",
|
|
191
|
+
"origin": "MANUAL",
|
|
192
|
+
"text": "This ticket can be handed off to a technician.",
|
|
193
|
+
"type": "escalation-offer",
|
|
194
|
+
},
|
|
195
|
+
"escalation_offer_superseded": {
|
|
196
|
+
"offerId": "offer-1",
|
|
197
|
+
"resolvedByName": "John Smith",
|
|
198
|
+
"status": "cancelled",
|
|
199
|
+
"type": "escalation-offer-resolved",
|
|
200
|
+
},
|
|
201
|
+
"escalation_offer_unknown_state": null,
|
|
182
202
|
"executed_tool": {
|
|
183
203
|
"data": {
|
|
184
204
|
"integratedToolType": "TACTICAL_RMM",
|
|
@@ -307,6 +327,15 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
|
|
|
307
327
|
"type": "thinking-delta",
|
|
308
328
|
},
|
|
309
329
|
"thinking_missing_text": null,
|
|
330
|
+
"ticket_escalated": {
|
|
331
|
+
"reason": "INACTIVITY",
|
|
332
|
+
"text": "Automatically escalated to a human technician.",
|
|
333
|
+
"ticketId": "ticket-1",
|
|
334
|
+
"ticketNumber": 1002,
|
|
335
|
+
"type": "ticket-escalated",
|
|
336
|
+
},
|
|
337
|
+
"ticket_escalated_missing_reason": null,
|
|
338
|
+
"ticket_escalated_missing_ticket_id": null,
|
|
310
339
|
"token_usage": {
|
|
311
340
|
"contextSize": 180000,
|
|
312
341
|
"inputTokensSize": 1200,
|
|
@@ -161,6 +161,38 @@ const CORPUS: Record<string, unknown> = {
|
|
|
161
161
|
command: 'echo hi',
|
|
162
162
|
},
|
|
163
163
|
|
|
164
|
+
escalation_offer_pending: {
|
|
165
|
+
type: 'ESCALATION_OFFER',
|
|
166
|
+
offerId: 'offer-1',
|
|
167
|
+
state: 'PENDING',
|
|
168
|
+
text: 'This ticket can be handed off to a technician.',
|
|
169
|
+
origin: 'MANUAL',
|
|
170
|
+
},
|
|
171
|
+
escalation_offer_approved: {
|
|
172
|
+
type: 'ESCALATION_OFFER',
|
|
173
|
+
offerId: 'offer-1',
|
|
174
|
+
state: 'APPROVED',
|
|
175
|
+
displayName: 'John Smith',
|
|
176
|
+
},
|
|
177
|
+
escalation_offer_superseded: {
|
|
178
|
+
type: 'ESCALATION_OFFER',
|
|
179
|
+
offerId: 'offer-1',
|
|
180
|
+
state: 'SUPERSEDED',
|
|
181
|
+
resolvedByName: 'John Smith',
|
|
182
|
+
},
|
|
183
|
+
escalation_offer_unknown_state: { type: 'ESCALATION_OFFER', offerId: 'offer-1', state: 'WAT' },
|
|
184
|
+
escalation_offer_missing_id: { type: 'ESCALATION_OFFER', state: 'PENDING' },
|
|
185
|
+
|
|
186
|
+
ticket_escalated: {
|
|
187
|
+
type: 'TICKET_ESCALATED',
|
|
188
|
+
ticketId: 'ticket-1',
|
|
189
|
+
ticketNumber: 1002,
|
|
190
|
+
reason: 'INACTIVITY',
|
|
191
|
+
text: 'Automatically escalated to a human technician.',
|
|
192
|
+
},
|
|
193
|
+
ticket_escalated_missing_reason: { type: 'TICKET_ESCALATED', ticketId: 'ticket-1' },
|
|
194
|
+
ticket_escalated_missing_ticket_id: { type: 'TICKET_ESCALATED', reason: 'INACTIVITY' },
|
|
195
|
+
|
|
164
196
|
approval_result_approved: {
|
|
165
197
|
type: 'APPROVAL_RESULT',
|
|
166
198
|
approvalRequestId: 'req-1',
|
|
@@ -160,6 +160,69 @@ export interface ApprovalResolvedEvent extends ChatStreamEventBase {
|
|
|
160
160
|
willAutoContinue?: boolean
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** The client is offered a handoff of this ticket to a human technician
|
|
164
|
+
* (NATS `ESCALATION_OFFER` chunk in state PENDING). Distinct from
|
|
165
|
+
* `approval-request`: it resolves through the ticket-escalation mutations,
|
|
166
|
+
* never the tool-approval flow, and it is raised by four different origins
|
|
167
|
+
* (Fae's own tool call, the client's header button, a deterministic
|
|
168
|
+
* trigger, or a deferred surfacing at turn end) that all render alike. */
|
|
169
|
+
export interface EscalationOfferEvent extends ChatStreamEventBase {
|
|
170
|
+
type: 'escalation-offer'
|
|
171
|
+
offerId: string
|
|
172
|
+
text: string
|
|
173
|
+
origin?: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Wire vocabulary of `EscalationOfferData.state`. */
|
|
177
|
+
export const ESCALATION_STATE = {
|
|
178
|
+
PENDING: 'PENDING',
|
|
179
|
+
APPROVED: 'APPROVED',
|
|
180
|
+
DECLINED: 'DECLINED',
|
|
181
|
+
SUPERSEDED: 'SUPERSEDED',
|
|
182
|
+
} as const
|
|
183
|
+
|
|
184
|
+
/** Terminal wire state → the shared `ChatApprovalStatus` vocabulary; `null`
|
|
185
|
+
* for PENDING or anything unrecognized. Shared by BOTH decoders (live
|
|
186
|
+
* chunks and persisted rows) so a state can never mean two things. */
|
|
187
|
+
export function escalationResolvedStatus(
|
|
188
|
+
state: unknown,
|
|
189
|
+
): EscalationOfferResolvedEvent['status'] | null {
|
|
190
|
+
switch (state) {
|
|
191
|
+
case ESCALATION_STATE.APPROVED:
|
|
192
|
+
return 'approved'
|
|
193
|
+
case ESCALATION_STATE.DECLINED:
|
|
194
|
+
return 'rejected'
|
|
195
|
+
case ESCALATION_STATE.SUPERSEDED:
|
|
196
|
+
return 'cancelled'
|
|
197
|
+
default:
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** An escalation offer reached a terminal state. The wire's SUPERSEDED
|
|
203
|
+
* (the client typed over a pending offer) maps onto `cancelled` so the
|
|
204
|
+
* whole stack keeps ONE status vocabulary (`ChatApprovalStatus`). The
|
|
205
|
+
* resolved chunk carries no text — consumers flip the existing block. */
|
|
206
|
+
export interface EscalationOfferResolvedEvent extends ChatStreamEventBase {
|
|
207
|
+
type: 'escalation-offer-resolved'
|
|
208
|
+
offerId: string
|
|
209
|
+
status: 'approved' | 'rejected' | 'cancelled'
|
|
210
|
+
resolvedByName?: string | null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The conversation was handed off to a human technician (`TICKET_ESCALATED`).
|
|
214
|
+
* A first-class block rather than something inferred from an offer's state,
|
|
215
|
+
* so paths that raise no offer at all — the inactivity auto-escalation — still
|
|
216
|
+
* produce a receipt. `text` is nullable on the wire; consumers supply the
|
|
217
|
+
* fallback copy. */
|
|
218
|
+
export interface TicketEscalatedEvent extends ChatStreamEventBase {
|
|
219
|
+
type: 'ticket-escalated'
|
|
220
|
+
ticketId: string
|
|
221
|
+
ticketNumber?: number
|
|
222
|
+
reason: string
|
|
223
|
+
text?: string
|
|
224
|
+
}
|
|
225
|
+
|
|
163
226
|
/** Per-turn metadata. Raw wire values pass through UNVALIDATED — the
|
|
164
227
|
* consumer replicates the legacy truthiness/typeof gates (so a
|
|
165
228
|
* malformed frame degrades identically to the pre-SSOT parser). */
|
|
@@ -265,6 +328,9 @@ export type ChatStreamEvent =
|
|
|
265
328
|
| ToolExecutionEvent
|
|
266
329
|
| ApprovalRequestEvent
|
|
267
330
|
| ApprovalResolvedEvent
|
|
331
|
+
| EscalationOfferEvent
|
|
332
|
+
| EscalationOfferResolvedEvent
|
|
333
|
+
| TicketEscalatedEvent
|
|
268
334
|
| ChatMetadataEvent
|
|
269
335
|
| UsageEvent
|
|
270
336
|
| TokenUsageEvent
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { MESSAGE_TYPE } from '../components/chat/types/message.types'
|
|
20
20
|
import type { AskOptionData } from '../components/chat/types/message.types'
|
|
21
|
+
import { ESCALATION_STATE, escalationResolvedStatus } from './events'
|
|
21
22
|
import type { ApprovalToolCall, ChatStreamEvent } from './events'
|
|
22
23
|
|
|
23
24
|
/** Minimal structural view of a NATS chunk (see `ChunkData` in
|
|
@@ -215,6 +216,52 @@ export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
|
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
case MESSAGE_TYPE.ESCALATION_OFFER: {
|
|
220
|
+
const offerId = typeof data.offerId === 'string' ? data.offerId : ''
|
|
221
|
+
if (!offerId) return null
|
|
222
|
+
if (data.state === ESCALATION_STATE.PENDING) {
|
|
223
|
+
return {
|
|
224
|
+
type: 'escalation-offer',
|
|
225
|
+
offerId,
|
|
226
|
+
text: typeof data.text === 'string' ? data.text : '',
|
|
227
|
+
origin: typeof data.origin === 'string' ? data.origin : undefined,
|
|
228
|
+
...seq,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const status = escalationResolvedStatus(data.state)
|
|
232
|
+
if (!status) return null
|
|
233
|
+
// Same realtime/history field split as APPROVAL_RESULT: the chunk names
|
|
234
|
+
// the resolver `displayName`, the persisted row `resolvedByName`.
|
|
235
|
+
return {
|
|
236
|
+
type: 'escalation-offer-resolved',
|
|
237
|
+
offerId,
|
|
238
|
+
status,
|
|
239
|
+
resolvedByName:
|
|
240
|
+
typeof data.resolvedByName === 'string'
|
|
241
|
+
? data.resolvedByName
|
|
242
|
+
: typeof data.displayName === 'string'
|
|
243
|
+
? data.displayName
|
|
244
|
+
: undefined,
|
|
245
|
+
...seq,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case MESSAGE_TYPE.TICKET_ESCALATED: {
|
|
250
|
+
const ticketId = typeof data.ticketId === 'string' ? data.ticketId : ''
|
|
251
|
+
const reason = typeof data.reason === 'string' ? data.reason : ''
|
|
252
|
+
// Both are non-null on the wire; a payload missing either is malformed
|
|
253
|
+
// rather than a variant to render.
|
|
254
|
+
if (!ticketId || !reason) return null
|
|
255
|
+
return {
|
|
256
|
+
type: 'ticket-escalated',
|
|
257
|
+
ticketId,
|
|
258
|
+
reason,
|
|
259
|
+
ticketNumber: typeof data.ticketNumber === 'number' ? data.ticketNumber : undefined,
|
|
260
|
+
text: typeof data.text === 'string' ? data.text : undefined,
|
|
261
|
+
...seq,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
218
265
|
case MESSAGE_TYPE.ERROR:
|
|
219
266
|
return {
|
|
220
267
|
type: 'error',
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ticket-escalation offer: decode → accumulate → resolve, across both the
|
|
3
|
+
* live NATS path and history replay.
|
|
4
|
+
*
|
|
5
|
+
* The two behaviours worth pinning are the ones that were expensive to get
|
|
6
|
+
* right, not the happy path:
|
|
7
|
+
* - an offer arriving with NO preceding MESSAGE_START must APPEND to the
|
|
8
|
+
* trailing bubble, never replace its segments (the backend surfaces
|
|
9
|
+
* deferred offers after the turn ends, so this is the normal shape, and
|
|
10
|
+
* a cumulative emit there wipes the reply the user is reading);
|
|
11
|
+
* - an offer and its resolution are two persisted rows separated by the
|
|
12
|
+
* user message that caused SUPERSEDED, so the flip has to reach a bubble
|
|
13
|
+
* that was already flushed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder'
|
|
18
|
+
import { createChatStreamReducer } from '../stream/chat-stream-reducer'
|
|
19
|
+
import { processHistoricalMessages } from '../utils/process-historical-messages'
|
|
20
|
+
import type { EscalationOfferSegment, HistoricalMessage, MessageSegment } from '../types'
|
|
21
|
+
|
|
22
|
+
const OFFER_ID = 'offer-1'
|
|
23
|
+
const OFFER_TEXT = 'Having issues with the AI assistant? This ticket can be handed off to a technician.'
|
|
24
|
+
|
|
25
|
+
const offerChunk = (streamSeq: number) => ({
|
|
26
|
+
type: 'ESCALATION_OFFER',
|
|
27
|
+
offerId: OFFER_ID,
|
|
28
|
+
state: 'PENDING',
|
|
29
|
+
text: OFFER_TEXT,
|
|
30
|
+
origin: 'MANUAL',
|
|
31
|
+
streamSeq,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const resolvedChunk = (state: string, streamSeq: number) => ({
|
|
35
|
+
type: 'ESCALATION_OFFER',
|
|
36
|
+
offerId: OFFER_ID,
|
|
37
|
+
state,
|
|
38
|
+
displayName: 'John Smith',
|
|
39
|
+
streamSeq,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const offers = (segments: MessageSegment[] | string | undefined): EscalationOfferSegment[] =>
|
|
43
|
+
Array.isArray(segments)
|
|
44
|
+
? (segments.filter((s) => s.type === 'escalation_offer') as EscalationOfferSegment[])
|
|
45
|
+
: []
|
|
46
|
+
|
|
47
|
+
const historyRow = (id: string, data: Record<string, unknown>): HistoricalMessage => ({
|
|
48
|
+
id,
|
|
49
|
+
createdAt: '2026-08-06T12:00:00Z',
|
|
50
|
+
owner: { type: 'ASSISTANT' },
|
|
51
|
+
messageData: [data as never],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('decodeNatsChunk — ESCALATION_OFFER', () => {
|
|
55
|
+
it('decodes the PENDING chunk as an offer', () => {
|
|
56
|
+
expect(decodeNatsChunk(offerChunk(1))).toEqual({
|
|
57
|
+
type: 'escalation-offer',
|
|
58
|
+
offerId: OFFER_ID,
|
|
59
|
+
text: OFFER_TEXT,
|
|
60
|
+
origin: 'MANUAL',
|
|
61
|
+
seq: 1,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('maps the terminal states onto the shared approval-status vocabulary', () => {
|
|
66
|
+
expect(decodeNatsChunk(resolvedChunk('APPROVED', 2))).toMatchObject({ status: 'approved' })
|
|
67
|
+
expect(decodeNatsChunk(resolvedChunk('DECLINED', 3))).toMatchObject({ status: 'rejected' })
|
|
68
|
+
expect(decodeNatsChunk(resolvedChunk('SUPERSEDED', 4))).toMatchObject({ status: 'cancelled' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('reads the resolver from `displayName` (chunk) or `resolvedByName` (row)', () => {
|
|
72
|
+
expect(decodeNatsChunk(resolvedChunk('APPROVED', 5))).toMatchObject({
|
|
73
|
+
resolvedByName: 'John Smith',
|
|
74
|
+
})
|
|
75
|
+
expect(
|
|
76
|
+
decodeNatsChunk({ ...resolvedChunk('APPROVED', 6), displayName: undefined, resolvedByName: 'Ada' }),
|
|
77
|
+
).toMatchObject({ resolvedByName: 'Ada' })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('drops malformed chunks instead of emitting a card with no id', () => {
|
|
81
|
+
expect(decodeNatsChunk({ type: 'ESCALATION_OFFER', state: 'PENDING' })).toBeNull()
|
|
82
|
+
expect(decodeNatsChunk({ type: 'ESCALATION_OFFER', offerId: OFFER_ID, state: 'WAT' })).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('chat stream reducer — escalation offer', () => {
|
|
87
|
+
const trailingSegments = (reducer: ReturnType<typeof createChatStreamReducer>): MessageSegment[] => {
|
|
88
|
+
const { messages } = reducer.state
|
|
89
|
+
return messages[messages.length - 1]?.segments ?? []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const feed = (reducer: ReturnType<typeof createChatStreamReducer>, chunks: unknown[]) => {
|
|
93
|
+
for (const chunk of chunks) {
|
|
94
|
+
const event = decodeNatsChunk(chunk)
|
|
95
|
+
if (event) reducer.apply(event)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
it('appends to the trailing bubble when the offer arrives after MESSAGE_END', () => {
|
|
100
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
101
|
+
feed(reducer, [
|
|
102
|
+
{ type: 'MESSAGE_START', streamSeq: 1 },
|
|
103
|
+
{ type: 'TEXT', text: 'Let me take a look at that.', streamSeq: 2 },
|
|
104
|
+
{ type: 'MESSAGE_END', streamSeq: 3 },
|
|
105
|
+
offerChunk(4),
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
const segments = trailingSegments(reducer)
|
|
109
|
+
// The prose the user was reading survives — this is the whole point.
|
|
110
|
+
expect(segments.find((s: MessageSegment) => s.type === 'text')).toMatchObject({
|
|
111
|
+
text: 'Let me take a look at that.',
|
|
112
|
+
})
|
|
113
|
+
expect(offers(segments)).toHaveLength(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('flips the card in place on resolution and stamps the resolver', () => {
|
|
117
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
118
|
+
feed(reducer, [
|
|
119
|
+
{ type: 'MESSAGE_START', streamSeq: 1 },
|
|
120
|
+
offerChunk(2),
|
|
121
|
+
resolvedChunk('APPROVED', 3),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
const rendered = offers(trailingSegments(reducer))
|
|
125
|
+
expect(rendered).toHaveLength(1)
|
|
126
|
+
expect(rendered[0]).toMatchObject({ status: 'approved', resolvedByName: 'John Smith' })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does not claim the agent is working while an offer is pending', () => {
|
|
130
|
+
const busy: unknown[] = []
|
|
131
|
+
const reducer = createChatStreamReducer({
|
|
132
|
+
transport: 'nats',
|
|
133
|
+
onEffect: (effect) => {
|
|
134
|
+
if (effect.name === 'onAgentBusy') busy.push(effect)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
feed(reducer, [{ type: 'MESSAGE_START', streamSeq: 1 }, offerChunk(2), resolvedChunk('APPROVED', 3)])
|
|
138
|
+
expect(busy).toHaveLength(0)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('is idempotent across a redelivered offer (JetStream catch-up)', () => {
|
|
142
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
143
|
+
feed(reducer, [{ type: 'MESSAGE_START', streamSeq: 1 }, offerChunk(2), { type: 'MESSAGE_END', streamSeq: 3 }])
|
|
144
|
+
// A replay re-decodes the same chunk with its original seq; the seq gate
|
|
145
|
+
// drops it, but the upsert must hold even if the gate ever moves.
|
|
146
|
+
const event = decodeNatsChunk({ ...offerChunk(2), streamSeq: 9 })
|
|
147
|
+
if (event) reducer.apply(event)
|
|
148
|
+
expect(offers(trailingSegments(reducer))).toHaveLength(1)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('processHistoricalMessages — escalation offer', () => {
|
|
153
|
+
it('renders the offer inline rather than deferring it to the sticky flush', () => {
|
|
154
|
+
const { messages } = processHistoricalMessages([
|
|
155
|
+
historyRow('m1', {
|
|
156
|
+
type: 'ESCALATION_OFFER',
|
|
157
|
+
offerId: OFFER_ID,
|
|
158
|
+
state: 'PENDING',
|
|
159
|
+
text: OFFER_TEXT,
|
|
160
|
+
origin: 'MANUAL',
|
|
161
|
+
}),
|
|
162
|
+
])
|
|
163
|
+
expect(messages).toHaveLength(1)
|
|
164
|
+
expect(offers(messages[0].content)[0]).toMatchObject({ status: 'pending' })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('flips a card that was flushed into an EARLIER bubble than its resolution', () => {
|
|
168
|
+
const { messages } = processHistoricalMessages([
|
|
169
|
+
historyRow('m1', {
|
|
170
|
+
type: 'ESCALATION_OFFER',
|
|
171
|
+
offerId: OFFER_ID,
|
|
172
|
+
state: 'PENDING',
|
|
173
|
+
text: OFFER_TEXT,
|
|
174
|
+
}),
|
|
175
|
+
// The user typing is exactly what produces SUPERSEDED, so this message
|
|
176
|
+
// is not incidental — it is what splits the two rows apart.
|
|
177
|
+
{
|
|
178
|
+
id: 'm2',
|
|
179
|
+
createdAt: '2026-08-06T12:01:00Z',
|
|
180
|
+
owner: { type: 'CLIENT' },
|
|
181
|
+
messageData: [{ type: 'TEXT', text: 'actually, never mind' } as never],
|
|
182
|
+
},
|
|
183
|
+
historyRow('m3', {
|
|
184
|
+
type: 'ESCALATION_OFFER',
|
|
185
|
+
offerId: OFFER_ID,
|
|
186
|
+
state: 'SUPERSEDED',
|
|
187
|
+
resolvedByName: 'John Smith',
|
|
188
|
+
}),
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
const rendered = messages.flatMap((m) => offers(m.content))
|
|
192
|
+
expect(rendered).toHaveLength(1)
|
|
193
|
+
expect(rendered[0]).toMatchObject({ status: 'cancelled', resolvedByName: 'John Smith' })
|
|
194
|
+
// Card text comes from the PENDING row; the resolution row carries none.
|
|
195
|
+
expect(rendered[0].data.text).toBe(OFFER_TEXT)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('honours the host overlay when the resolution row is not in the fetched pages', () => {
|
|
199
|
+
const { messages } = processHistoricalMessages(
|
|
200
|
+
[
|
|
201
|
+
historyRow('m1', {
|
|
202
|
+
type: 'ESCALATION_OFFER',
|
|
203
|
+
offerId: OFFER_ID,
|
|
204
|
+
state: 'PENDING',
|
|
205
|
+
text: OFFER_TEXT,
|
|
206
|
+
}),
|
|
207
|
+
],
|
|
208
|
+
{ escalationOfferStates: { [OFFER_ID]: 'approved' } },
|
|
209
|
+
)
|
|
210
|
+
expect(messages.flatMap((m) => offers(m.content))[0]).toMatchObject({ status: 'approved' })
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('TICKET_ESCALATED — the handoff receipt', () => {
|
|
215
|
+
const escalated = (over = {}) => ({
|
|
216
|
+
type: 'TICKET_ESCALATED',
|
|
217
|
+
ticketId: 't-1',
|
|
218
|
+
ticketNumber: 1002,
|
|
219
|
+
reason: 'INACTIVITY',
|
|
220
|
+
text: 'Automatically escalated to a human technician because the conversation had no new messages.',
|
|
221
|
+
...over,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const receipts = (segments: MessageSegment[] | string | undefined) =>
|
|
225
|
+
Array.isArray(segments) ? segments.filter((s) => s.type === 'ticket_escalated') : []
|
|
226
|
+
|
|
227
|
+
it('decodes the chunk, keeping the backend copy verbatim', () => {
|
|
228
|
+
expect(decodeNatsChunk({ ...escalated(), streamSeq: 42 })).toEqual({
|
|
229
|
+
type: 'ticket-escalated',
|
|
230
|
+
ticketId: 't-1',
|
|
231
|
+
ticketNumber: 1002,
|
|
232
|
+
reason: 'INACTIVITY',
|
|
233
|
+
text: 'Automatically escalated to a human technician because the conversation had no new messages.',
|
|
234
|
+
seq: 42,
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('drops payloads missing a non-null wire field', () => {
|
|
239
|
+
expect(decodeNatsChunk({ type: 'TICKET_ESCALATED', reason: 'INACTIVITY' })).toBeNull()
|
|
240
|
+
expect(decodeNatsChunk({ type: 'TICKET_ESCALATED', ticketId: 't-1' })).toBeNull()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('survives an unknown reason without a client change', () => {
|
|
244
|
+
expect(decodeNatsChunk(escalated({ reason: 'USER_REQUESTED' }))).toMatchObject({
|
|
245
|
+
reason: 'USER_REQUESTED',
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('appends to the trailing bubble when it arrives with no turn open', () => {
|
|
250
|
+
// The inactivity auto-escalation fires from a scheduler: no MESSAGE_START
|
|
251
|
+
// precedes it, so a cumulative emit here would wipe the last reply.
|
|
252
|
+
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
253
|
+
for (const chunk of [
|
|
254
|
+
{ type: 'MESSAGE_START', streamSeq: 1 },
|
|
255
|
+
{ type: 'TEXT', text: 'Let me look into that.', streamSeq: 2 },
|
|
256
|
+
{ type: 'MESSAGE_END', streamSeq: 3 },
|
|
257
|
+
{ ...escalated(), streamSeq: 4 },
|
|
258
|
+
]) {
|
|
259
|
+
const event = decodeNatsChunk(chunk)
|
|
260
|
+
if (event) reducer.apply(event)
|
|
261
|
+
}
|
|
262
|
+
const { messages } = reducer.state
|
|
263
|
+
const segments = messages[messages.length - 1]?.segments ?? []
|
|
264
|
+
expect(segments.find((s: MessageSegment) => s.type === 'text')).toMatchObject({
|
|
265
|
+
text: 'Let me look into that.',
|
|
266
|
+
})
|
|
267
|
+
expect(receipts(segments)).toHaveLength(1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('renders on history replay with no escalation offer anywhere in the thread', () => {
|
|
271
|
+
const { messages } = processHistoricalMessages([historyRow('m1', escalated())])
|
|
272
|
+
const rendered = messages.flatMap((m) => receipts(m.content))
|
|
273
|
+
expect(rendered).toHaveLength(1)
|
|
274
|
+
expect(rendered[0]).toMatchObject({ data: { ticketId: 't-1', reason: 'INACTIVITY' } })
|
|
275
|
+
})
|
|
276
|
+
})
|
|
@@ -8,6 +8,8 @@ import { SquareAvatar } from "../ui/square-avatar"
|
|
|
8
8
|
import { ToolExecutionDisplay } from "./tool-execution-display"
|
|
9
9
|
import { ApprovalRequestMessage } from "./approval-request-message"
|
|
10
10
|
import { ApprovalBatchMessage } from "./approval-batch-message"
|
|
11
|
+
import { EscalationOfferMessage } from "./escalation-offer-message"
|
|
12
|
+
import { TicketEscalatedMessage } from "./ticket-escalated-message"
|
|
11
13
|
import { ErrorMessageDisplay } from "./error-message-display"
|
|
12
14
|
import { ContextCompactionDisplay } from "./context-compaction-display"
|
|
13
15
|
import { ThinkingDisplay } from "./thinking-display"
|
|
@@ -725,6 +727,19 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
725
727
|
variant={approvalVariant}
|
|
726
728
|
/>
|
|
727
729
|
)
|
|
730
|
+
} else if (segment.type === 'escalation_offer') {
|
|
731
|
+
return (
|
|
732
|
+
<EscalationOfferMessage
|
|
733
|
+
key={index}
|
|
734
|
+
data={segment.data}
|
|
735
|
+
status={segment.status}
|
|
736
|
+
resolvedByName={segment.resolvedByName}
|
|
737
|
+
onApprove={segment.onApprove}
|
|
738
|
+
onReject={segment.onReject}
|
|
739
|
+
/>
|
|
740
|
+
)
|
|
741
|
+
} else if (segment.type === 'ticket_escalated') {
|
|
742
|
+
return <TicketEscalatedMessage key={index} data={segment.data} timestamp={timestamp} />
|
|
728
743
|
} else if (segment.type === 'error') {
|
|
729
744
|
return (
|
|
730
745
|
<ErrorMessageDisplay
|
|
@@ -897,7 +897,9 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
897
897
|
const lastSegment = lastSegments?.[lastSegments.length - 1]
|
|
898
898
|
const isPausedOnApproval =
|
|
899
899
|
!!lastSegment &&
|
|
900
|
-
(lastSegment.type === 'approval_request' ||
|
|
900
|
+
(lastSegment.type === 'approval_request' ||
|
|
901
|
+
lastSegment.type === 'approval_batch' ||
|
|
902
|
+
lastSegment.type === 'escalation_offer') &&
|
|
901
903
|
(lastSegment.status === undefined || lastSegment.status === 'pending')
|
|
902
904
|
const showStreamingLoader =
|
|
903
905
|
isTyping &&
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import { ApprovalRequestMessage } from "./approval-request-message"
|
|
6
|
+
import type { EscalationOfferSegment } from "./types/message.types"
|
|
7
|
+
|
|
8
|
+
export interface EscalationOfferMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
data: EscalationOfferSegment['data']
|
|
10
|
+
status?: EscalationOfferSegment['status']
|
|
11
|
+
resolvedByName?: string | null
|
|
12
|
+
onApprove?: EscalationOfferSegment['onApprove']
|
|
13
|
+
onReject?: EscalationOfferSegment['onReject']
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ticket-escalation offer block.
|
|
18
|
+
*
|
|
19
|
+
* Renders THROUGH the client approval card rather than reimplementing it:
|
|
20
|
+
* per Figma (fae chat › "escalation to admin") the offer is that exact
|
|
21
|
+
* component, down to the Approve/Reject labels and the resolved
|
|
22
|
+
* "Approved by {name}" pill. Only the wiring differs — approve/reject call
|
|
23
|
+
* the ticket-escalation mutations, never the tool-approval endpoint — which
|
|
24
|
+
* is why this is a distinct segment type with its own handlers.
|
|
25
|
+
*
|
|
26
|
+
* The handoff receipt that follows an approval is NOT rendered here: it is its
|
|
27
|
+
* own `TICKET_ESCALATED` block on the wire, which also covers the inactivity
|
|
28
|
+
* auto-escalation — a path that raises no offer to infer from.
|
|
29
|
+
*/
|
|
30
|
+
const EscalationOfferMessage = forwardRef<HTMLDivElement, EscalationOfferMessageProps>(
|
|
31
|
+
({ className, data, status = 'pending', resolvedByName, onApprove, onReject, ...props }, ref) => (
|
|
32
|
+
<div ref={ref} className={cn("flex flex-col", className)} {...props}>
|
|
33
|
+
<ApprovalRequestMessage
|
|
34
|
+
variant="client"
|
|
35
|
+
// The client card renders `explanation` as its body and never shows
|
|
36
|
+
// `command`; the offer's whole payload is that one backend-fixed line.
|
|
37
|
+
data={{ command: '', explanation: data.text, requestId: data.offerId }}
|
|
38
|
+
status={status}
|
|
39
|
+
resolvedByName={resolvedByName}
|
|
40
|
+
onApprove={onApprove}
|
|
41
|
+
onReject={onReject}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
EscalationOfferMessage.displayName = "EscalationOfferMessage"
|
|
48
|
+
|
|
49
|
+
export { EscalationOfferMessage }
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Chat components exports
|
|
4
4
|
export * from './approval-request-message'
|
|
5
5
|
export * from './approval-batch-message'
|
|
6
|
+
export * from './escalation-offer-message'
|
|
7
|
+
export * from './ticket-escalated-message'
|
|
6
8
|
export * from './context-compaction-display'
|
|
7
9
|
export * from './expand-chevron'
|
|
8
10
|
export * from './thinking-display'
|
|
@@ -197,6 +197,7 @@ export interface ChatReducerEffect {
|
|
|
197
197
|
| 'onEscalatedApproval'
|
|
198
198
|
| 'onEscalatedApprovalResult'
|
|
199
199
|
| 'onApprovalResolved'
|
|
200
|
+
| 'onEscalationOfferResolved'
|
|
200
201
|
| 'onToolExecuted'
|
|
201
202
|
| 'onAgentBusy'
|
|
202
203
|
| 'onDialogClosed'
|
|
@@ -1275,6 +1276,55 @@ export function createChatStreamReducer(
|
|
|
1275
1276
|
break
|
|
1276
1277
|
}
|
|
1277
1278
|
|
|
1279
|
+
case 'escalation-offer': {
|
|
1280
|
+
// MUST route through `applyAccumulated`. The block reaches a client
|
|
1281
|
+
// in three shapes — inline in Fae's live turn (tool origin), or with
|
|
1282
|
+
// NO preceding MESSAGE_START when the backend surfaces a deferred
|
|
1283
|
+
// offer at turn end or honours the header button while idle. In the
|
|
1284
|
+
// latter shapes a cumulative emit would replace the trailing bubble's
|
|
1285
|
+
// segments with the lone card, wiping the reply the user is reading.
|
|
1286
|
+
const status = (approvalStatuses[event.offerId] || 'pending') as ChatApprovalStatus
|
|
1287
|
+
const before = accumulator.getSegments().length
|
|
1288
|
+
const segments = accumulator.addEscalationOffer(
|
|
1289
|
+
event.offerId,
|
|
1290
|
+
event.text,
|
|
1291
|
+
event.origin,
|
|
1292
|
+
status,
|
|
1293
|
+
)
|
|
1294
|
+
applyAccumulated(before, segments)
|
|
1295
|
+
break
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
case 'escalation-offer-resolved': {
|
|
1299
|
+
const { offerId, status, resolvedByName: offerResolvedBy } = event
|
|
1300
|
+
// Approving hands the ticket to a human; Fae goes silent rather than
|
|
1301
|
+
// resuming, so — unlike an approved command — there is no work to
|
|
1302
|
+
// signal and the busy lock stays untouched.
|
|
1303
|
+
accumulator.updateApprovalStatus(offerId, status, offerResolvedBy)
|
|
1304
|
+
setMessagesInternal(
|
|
1305
|
+
projectApprovalResolutionToMessages(messages, offerId, status, offerResolvedBy),
|
|
1306
|
+
)
|
|
1307
|
+
mirrorApprovalStatus(offerId, status)
|
|
1308
|
+
emit('onEscalationOfferResolved', offerId, status, offerResolvedBy)
|
|
1309
|
+
break
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
case 'ticket-escalated': {
|
|
1313
|
+
// Same `applyAccumulated` routing as the offer, and for the same
|
|
1314
|
+
// reason: the inactivity auto-escalation fires from a scheduler with
|
|
1315
|
+
// no turn open at all, so this block routinely arrives with no
|
|
1316
|
+
// preceding MESSAGE_START.
|
|
1317
|
+
const before = accumulator.getSegments().length
|
|
1318
|
+
const segments = accumulator.addTicketEscalated({
|
|
1319
|
+
ticketId: event.ticketId,
|
|
1320
|
+
ticketNumber: event.ticketNumber,
|
|
1321
|
+
reason: event.reason,
|
|
1322
|
+
text: event.text,
|
|
1323
|
+
})
|
|
1324
|
+
applyAccumulated(before, segments)
|
|
1325
|
+
break
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1278
1328
|
case 'error': {
|
|
1279
1329
|
let message: string | undefined
|
|
1280
1330
|
if (event.details) {
|