phoenix_duskmoon 10.0.11 → 10.1.0

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.
@@ -1,28 +1,51 @@
1
1
  import React from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import {
4
+ snapshotComposerDocument,
5
+ snapshotComposerReceipt,
6
+ } from "@duskmoon-dev/components/chat";
3
7
  import { ReactChat } from "./chat.js";
4
- import { initialState, reduceChat } from "./state.js";
8
+ import {
9
+ initialState,
10
+ reduceChat,
11
+ sameChatScope,
12
+ validChatQueue,
13
+ } from "./state.js";
14
+ import {
15
+ assertChatJSON,
16
+ ChatTransportError,
17
+ correlatedReceipt,
18
+ requestId,
19
+ structuredSnapshot,
20
+ submissionSnapshot,
21
+ unknownReceipt,
22
+ chatJSONKey,
23
+ } from "./protocol.js";
5
24
 
6
25
  export { ReactChat, useChatSubmission } from "./chat.js";
7
26
  export { initialState, reduceChat } from "./state.js";
27
+ export { ChatTransportError } from "./protocol.js";
28
+
29
+ let nextGeneration = 0;
8
30
 
9
31
  export function createDuskmoonReactChat(options = {}) {
10
32
  return {
11
33
  mounted() {
12
- this.config = JSON.parse(this.el.dataset.chat);
13
- this.state = initialState(
14
- this.config.conversationId,
15
- this.config.messages,
16
- this.config,
17
- );
18
- this.root = createRoot(this.el);
19
34
  this.connected = true;
20
35
  this.requests = new Set();
21
- this.bridge = {
22
- send: (action, payload) => this.sendChat(action, payload),
23
- };
36
+ this.recoveryFailures = new Map();
37
+ this.bindContext(JSON.parse(this.el.dataset.chat));
38
+ this.root = createRoot(this.el);
24
39
  this.eventRef = this.handleEvent(this.config.event, (event) => {
25
40
  if (event.id !== this.el.id) return;
41
+ if (
42
+ ["chat.snapshot", "chat.reset"].includes(event.type) &&
43
+ event.conversation_id === this.state.conversationId &&
44
+ event.protocolVersion === 1 &&
45
+ sameChatScope(event.scope, this.state.scope) &&
46
+ event.instanceGeneration === this.state.instanceGeneration
47
+ )
48
+ this.reconcileReceipts(event.receipts);
26
49
  const previous = this.state;
27
50
  this.state = reduceChat(this.state, event);
28
51
  if (previous === this.state) return;
@@ -31,63 +54,449 @@ export function createDuskmoonReactChat(options = {}) {
31
54
  });
32
55
  this.renderChat();
33
56
  },
34
- sendChat(action, payload) {
35
- if (!this.connected || this.dead)
36
- return Promise.reject(new Error("Chat is disconnected"));
57
+ bindContext(config) {
58
+ if (config.protocolVersion != null && config.protocolVersion !== 1)
59
+ throw new TypeError("Unsupported chat protocol version");
60
+ const generation = ++nextGeneration;
61
+ if (
62
+ config.protocolVersion === 1 &&
63
+ (!config.scope?.contextKey ||
64
+ config.scope.conversationId !== config.conversationId)
65
+ )
66
+ throw new TypeError(
67
+ "Structured chat requires an application context key and matching conversation",
68
+ );
69
+ this.config = config;
70
+ this.state = initialState(config.conversationId, config.messages, {
71
+ ...config,
72
+ instanceGeneration: generation,
73
+ });
74
+ this.adapter =
75
+ options.adapter?.({
76
+ scope: this.state.scope,
77
+ instanceGeneration: generation,
78
+ config,
79
+ element: this.el,
80
+ }) || {};
81
+ this.submissionPending = false;
82
+ this.syncPending = null;
83
+ const current = () => {
84
+ if (this.dead || generation !== this.state.instanceGeneration)
85
+ throw new Error("Chat context has changed");
86
+ };
87
+ this.bridge = {
88
+ send: (action, payload) => {
89
+ try {
90
+ current();
91
+ return this.sendChat(action, payload);
92
+ } catch (error) {
93
+ return Promise.reject(error);
94
+ }
95
+ },
96
+ submit: async (snapshot, signal) => {
97
+ current();
98
+ return this.submitStructured(snapshot, signal);
99
+ },
100
+ reconcile: async (snapshot, signal) => {
101
+ current();
102
+ return this.reconcileSubmission(snapshot, signal);
103
+ },
104
+ mutateQueue: async (mutation, signal) => {
105
+ current();
106
+ return this.mutateQueue(mutation, signal);
107
+ },
108
+ getRecoveryFailures: () => [...this.recoveryFailures.values()],
109
+ };
110
+ },
111
+ // Explicit host operation. Mutating data-chat alone never changes ownership.
112
+ switchContext(config) {
113
+ if (config.event !== this.config.event)
114
+ throw new TypeError("Remount the hook to change its event channel");
115
+ this.requests.forEach((request) => request.cancel());
116
+ this.bindContext(config);
117
+ this.renderChat();
118
+ },
119
+ checkAction(action) {
120
+ if (
121
+ typeof this.config.actions?.[action] !== "string" ||
122
+ !this.config.actions[action].trim()
123
+ )
124
+ throw new TypeError(`Chat action is not configured: ${action}`);
125
+ if (!this.connected || this.dead) throw new Error("Chat is disconnected");
37
126
  if (action === "send" && this.state.needsSync)
38
- return Promise.reject(new Error("Chat is synchronizing"));
127
+ throw new Error("Chat is synchronizing");
128
+ if (this.requests.size >= (options.maxPendingRequests ?? 64))
129
+ throw new Error(
130
+ "Chat transport capacity reached; reconcile pending requests",
131
+ );
132
+ },
133
+ sendChat(action, payload = {}) {
134
+ if (
135
+ action === "send" &&
136
+ (payload.protocolVersion !== undefined ||
137
+ payload.document !== undefined)
138
+ )
139
+ return this.submitStructured(payload);
140
+ return this.transport(action, payload);
141
+ },
142
+ transport(action, payload, structured) {
143
+ try {
144
+ this.checkAction(action);
145
+ assertChatJSON(payload);
146
+ } catch (error) {
147
+ return Promise.reject(error);
148
+ }
149
+ const generation = this.state.instanceGeneration;
150
+ const scope = this.state.scope;
151
+ const correlation = {
152
+ request_id: payload.request_id ?? requestId(),
153
+ instanceGeneration: generation,
154
+ scope,
155
+ clientSubmissionId: structured?.snapshot?.clientSubmissionId,
156
+ };
157
+ if (
158
+ typeof correlation.request_id !== "string" ||
159
+ !correlation.request_id ||
160
+ [...this.requests].some((r) => r.id === correlation.request_id)
161
+ )
162
+ return Promise.reject(
163
+ new TypeError("Chat request_id must be unique while pending"),
164
+ );
165
+ const body = {
166
+ ...payload,
167
+ request_id: correlation.request_id,
168
+ id: this.el.id,
169
+ conversation_id: this.state.conversationId,
170
+ ...(this.state.protocolVersion === 1
171
+ ? { protocolVersion: 1, scope, instanceGeneration: generation }
172
+ : {}),
173
+ };
39
174
  return new Promise((resolve, reject) => {
175
+ const uncertain = (message, cause) =>
176
+ new ChatTransportError(
177
+ message,
178
+ { ...correlation, dispatched: true },
179
+ cause,
180
+ );
40
181
  const finish = (error, reply) => {
41
182
  if (!this.requests.delete(request)) return;
42
183
  clearTimeout(request.timer);
184
+ structured?.signal?.removeEventListener("abort", request.cancel);
43
185
  error ? reject(error) : resolve(reply);
44
186
  };
45
187
  const request = {
188
+ id: correlation.request_id,
46
189
  cancel: () =>
47
190
  finish(
48
- new Error("Chat disconnected; check status before retrying"),
191
+ uncertain("Chat disconnected; check status before retrying"),
49
192
  ),
50
193
  };
194
+ if (structured?.signal?.aborted) {
195
+ reject(new Error("Chat request aborted before dispatch"));
196
+ return;
197
+ }
51
198
  this.requests.add(request);
199
+ structured?.signal?.addEventListener("abort", request.cancel, {
200
+ once: true,
201
+ });
52
202
  request.timer = setTimeout(
53
203
  () =>
54
204
  finish(
55
- new Error("Chat request timed out; check status before retrying"),
205
+ uncertain("Chat request timed out; check status before retrying"),
56
206
  ),
57
- 15000,
207
+ options.requestTimeoutMs ?? 15000,
58
208
  );
59
- const body = {
60
- ...payload,
61
- id: this.el.id,
62
- conversation_id: this.state.conversationId,
63
- };
64
209
  const reply = (response) => {
65
- const error =
66
- response?.status === "ok"
67
- ? null
68
- : new Error(response?.message || "Chat request rejected");
69
- if (error) error.response = response;
70
- finish(error, response);
210
+ try {
211
+ if (structured) {
212
+ const receipt = structured.validate(response, correlation);
213
+ structured.onReceipt?.(receipt);
214
+ // A late receipt resolves its original recovery record only.
215
+ if (generation !== this.state.instanceGeneration || this.dead) {
216
+ finish(uncertain("Chat context has changed"));
217
+ return;
218
+ }
219
+ finish(null, receipt);
220
+ } else if (response?.status === "ok") finish(null, response);
221
+ else if (response?.status === "error") {
222
+ const error = new Error(
223
+ response.message || "Chat request rejected",
224
+ );
225
+ error.response = response;
226
+ finish(error);
227
+ } else
228
+ finish(uncertain("Missing or malformed chat acknowledgement"));
229
+ } catch (error) {
230
+ finish(
231
+ uncertain(
232
+ "Invalid chat acknowledgement; check status before retrying",
233
+ error,
234
+ ),
235
+ );
236
+ }
71
237
  };
72
238
  try {
73
239
  const event = this.config.actions[action];
74
- if (this.config.target || this.el.dataset.target)
75
- this.pushEventTo(
76
- this.config.target || this.el.dataset.target,
77
- event,
78
- body,
79
- reply,
80
- );
240
+ const target = this.config.target || this.el.dataset.target;
241
+ if (target) this.pushEventTo(target, event, body, reply);
81
242
  else this.pushEvent(event, body, reply);
82
243
  } catch (error) {
83
- finish(error);
244
+ finish(uncertain("Chat dispatch outcome is unknown", error));
84
245
  }
85
246
  });
86
247
  },
87
- requestSync() {
88
- this.sendChat("sync", { sequences: this.state.sequences }).catch(
89
- () => {},
248
+ retain(adapter, attempt) {
249
+ try {
250
+ adapter.recovery.retain(attempt);
251
+ this.recoveryFailures.delete(attempt.snapshot.clientSubmissionId);
252
+ } catch (error) {
253
+ this.recoveryFailures.set(attempt.snapshot.clientSubmissionId, {
254
+ attempt,
255
+ error,
256
+ });
257
+ throw error;
258
+ }
259
+ },
260
+ async submitStructured(payload, signal) {
261
+ if (signal?.aborted)
262
+ throw new Error("Chat request aborted before dispatch");
263
+ this.checkAction("send");
264
+ if (this.submissionPending)
265
+ throw new Error("Chat is not accepting messages");
266
+ const snapshot = structuredSnapshot(payload, this.state);
267
+ const adapter = this.adapter;
268
+ if (!adapter.recovery?.retain || !adapter.recovery?.getSnapshot)
269
+ throw new TypeError(
270
+ "Structured submissions require an application recovery owner",
271
+ );
272
+ const generation = this.state.instanceGeneration;
273
+ const previous = adapter.recovery
274
+ .getSnapshot()
275
+ .find(
276
+ (a) => a.snapshot.clientSubmissionId === snapshot.clientSubmissionId,
277
+ );
278
+ if (previous && chatJSONKey(previous.snapshot) !== chatJSONKey(snapshot))
279
+ throw new TypeError(
280
+ "A logical submission ID cannot change its payload",
281
+ );
282
+ if (previous?.status === "accepted" || previous?.status === "rejected")
283
+ return previous.receipt;
284
+ const physicalId = payload.request_id ?? requestId();
285
+ const attempt = {
286
+ snapshot,
287
+ instanceId: this.el.id,
288
+ instanceGeneration: generation,
289
+ createdAt: previous?.createdAt ?? Date.now(),
290
+ status: "pending",
291
+ requestIds: [...(previous?.requestIds || []), physicalId],
292
+ };
293
+ this.submissionPending = true;
294
+ try {
295
+ // The upstream controller may already have retained this first attempt.
296
+ if (
297
+ previous &&
298
+ (previous.status === "unknown" || previous.requestIds?.length) &&
299
+ (await adapter.authorizeRetry?.(previous)) !== true
300
+ )
301
+ throw new Error(
302
+ "Explicit application authorization is required to retry an unresolved submission",
303
+ );
304
+ // Handoff is synchronous and must succeed before dispatch becomes possible.
305
+ this.retain(adapter, attempt);
306
+ if (generation !== this.state.instanceGeneration || this.dead)
307
+ throw new Error("Chat context has changed");
308
+ return await this.transport(
309
+ "send",
310
+ { ...snapshot, request_id: physicalId },
311
+ {
312
+ snapshot,
313
+ signal,
314
+ validate: (response, correlation) =>
315
+ correlatedReceipt(response, snapshot, correlation),
316
+ onReceipt: (receipt) =>
317
+ this.retain(adapter, {
318
+ ...attempt,
319
+ status: receipt.outcome,
320
+ receipt,
321
+ }),
322
+ },
323
+ );
324
+ } catch (error) {
325
+ if (!(error instanceof ChatTransportError)) throw error;
326
+ const receipt = unknownReceipt(snapshot, error);
327
+ this.retain(adapter, { ...attempt, status: "unknown", receipt });
328
+ return receipt;
329
+ } finally {
330
+ if (generation === this.state.instanceGeneration)
331
+ this.submissionPending = false;
332
+ }
333
+ },
334
+ async reconcileSubmission(payload, signal) {
335
+ const snapshot = submissionSnapshot(payload, this.state);
336
+ const adapter = this.adapter;
337
+ const attempt = adapter.recovery
338
+ ?.getSnapshot()
339
+ .find(
340
+ (a) => a.snapshot.clientSubmissionId === snapshot.clientSubmissionId,
341
+ );
342
+ if (!attempt || chatJSONKey(attempt.snapshot) !== chatJSONKey(snapshot))
343
+ throw new Error("No matching scoped recovery record for submission");
344
+ try {
345
+ return await this.transport(
346
+ "reconcile",
347
+ {
348
+ clientSubmissionId: snapshot.clientSubmissionId,
349
+ operation: snapshot.operation,
350
+ },
351
+ {
352
+ snapshot,
353
+ signal,
354
+ validate: (response, correlation) =>
355
+ correlatedReceipt(response, snapshot, correlation),
356
+ onReceipt: (receipt) =>
357
+ this.retain(adapter, {
358
+ ...attempt,
359
+ status: receipt.outcome,
360
+ receipt,
361
+ }),
362
+ },
363
+ );
364
+ } catch (error) {
365
+ if (!(error instanceof ChatTransportError)) throw error;
366
+ const receipt = unknownReceipt(snapshot, error);
367
+ this.retain(adapter, { ...attempt, status: "unknown", receipt });
368
+ return receipt;
369
+ }
370
+ },
371
+ async mutateQueue(mutation, signal) {
372
+ assertChatJSON(mutation);
373
+ if (
374
+ !["edit", "remove", "promote"].includes(mutation.operation) ||
375
+ !mutation.requestId
376
+ )
377
+ throw new TypeError("Unsupported queue operation");
378
+ const { composer, scope } = this.state;
379
+ const item = composer?.queue?.items.find(
380
+ (item) => item.id === mutation.itemId,
90
381
  );
382
+ const capability = composer?.capabilities?.queue?.[mutation.operation];
383
+ const itemCapability = item?.capabilities?.[mutation.operation];
384
+ if (
385
+ !sameChatScope(mutation.scope, scope) ||
386
+ !item ||
387
+ !capability ||
388
+ capability === "none" ||
389
+ !itemCapability ||
390
+ itemCapability === "none"
391
+ )
392
+ throw new TypeError("Unsupported queue operation or item");
393
+ if (mutation.expectedRevision !== composer.queue.revision)
394
+ throw new TypeError("Queue revision has changed");
395
+ if (mutation.operation === "promote" && !mutation.targetTurnId)
396
+ throw new TypeError("Promotion requires a target turn");
397
+ if (mutation.operation === "edit")
398
+ snapshotComposerDocument(mutation.document);
399
+ if (
400
+ mutation.operation === "edit" &&
401
+ (!mutation.document ||
402
+ ([capability, itemCapability].includes("text") &&
403
+ (mutation.document.command ||
404
+ mutation.document.content?.some((node) => node.type !== "text"))))
405
+ )
406
+ throw new TypeError("Queue editing supports text documents only");
407
+ const generation = this.state.instanceGeneration;
408
+ return this.transport(`queue_${mutation.operation}`, mutation, {
409
+ signal,
410
+ validate: (response, correlation) => {
411
+ const result = response?.result;
412
+ assertChatJSON(result);
413
+ if (
414
+ response?.request_id !== correlation.request_id ||
415
+ response?.instanceGeneration !== generation ||
416
+ result?.requestId !== mutation.requestId ||
417
+ !["accepted", "rejected"].includes(result.outcome) ||
418
+ !validChatQueue(result.projection, scope) ||
419
+ (result.outcome === "rejected" && !result.error?.code)
420
+ )
421
+ throw new TypeError("Queue response correlation mismatch");
422
+ return result;
423
+ },
424
+ onReceipt: (result) => {
425
+ if (this.dead || generation !== this.state.instanceGeneration) return;
426
+ const projection = result.projection;
427
+ if (
428
+ Number.isSafeInteger(projection.revision) &&
429
+ projection.revision > this.state.composer.queue.revision &&
430
+ Array.isArray(projection.items)
431
+ ) {
432
+ this.state = {
433
+ ...this.state,
434
+ composer: { ...this.state.composer, queue: projection },
435
+ };
436
+ this.scheduleRender();
437
+ }
438
+ },
439
+ });
440
+ },
441
+ reconcileReceipts(receipts = []) {
442
+ if (!this.adapter.reconcileOnSync || !this.adapter.recovery) return;
443
+ if (!Array.isArray(receipts)) receipts = [];
444
+ for (const attempt of this.adapter.recovery.getSnapshot()) {
445
+ if (
446
+ !sameChatScope(attempt.snapshot.scope, this.state.scope) ||
447
+ !["pending", "unknown"].includes(attempt.status)
448
+ )
449
+ continue;
450
+ const input = receipts.find(
451
+ (r) => r.clientSubmissionId === attempt.snapshot.clientSubmissionId,
452
+ );
453
+ let receipt;
454
+ try {
455
+ receipt = input
456
+ ? snapshotComposerReceipt(input, attempt.snapshot)
457
+ : unknownReceipt(
458
+ attempt.snapshot,
459
+ new Error("No authoritative receipt in snapshot"),
460
+ );
461
+ } catch (error) {
462
+ receipt = unknownReceipt(attempt.snapshot, error);
463
+ }
464
+ this.retain(this.adapter, {
465
+ ...attempt,
466
+ status: receipt.outcome,
467
+ receipt,
468
+ });
469
+ }
470
+ },
471
+ requestSync() {
472
+ if (this.syncPending) return this.syncPending;
473
+ const generation = this.state.instanceGeneration;
474
+ const payload = { sequences: this.state.sequences };
475
+ if (this.adapter.reconcileOnSync && this.adapter.recovery)
476
+ payload.unresolvedSubmissionIds = this.adapter.recovery
477
+ .getSnapshot()
478
+ .filter(
479
+ (a) =>
480
+ sameChatScope(a.snapshot.scope, this.state.scope) &&
481
+ ["pending", "unknown"].includes(a.status),
482
+ )
483
+ .map((a) => a.snapshot.clientSubmissionId);
484
+ const pending = this.sendChat("sync", payload)
485
+ .catch((error) => {
486
+ if (
487
+ !this.dead &&
488
+ generation === this.state.instanceGeneration &&
489
+ this.syncPending === pending
490
+ ) {
491
+ this.state = { ...this.state, error: error.message };
492
+ this.scheduleRender();
493
+ }
494
+ })
495
+ .finally(() => {
496
+ if (this.syncPending === pending) this.syncPending = null;
497
+ });
498
+ this.syncPending = pending;
499
+ return pending;
91
500
  },
92
501
  scheduleRender() {
93
502
  if (!this.frame)
@@ -104,12 +513,14 @@ export function createDuskmoonReactChat(options = {}) {
104
513
  connected: this.connected,
105
514
  label: this.config.label,
106
515
  options,
516
+ adapter: this.adapter,
107
517
  }),
108
518
  );
109
519
  },
110
520
  disconnected() {
111
521
  this.connected = false;
112
522
  this.requests.forEach((request) => request.cancel());
523
+ this.syncPending = null;
113
524
  this.renderChat();
114
525
  },
115
526
  reconnected() {
@@ -0,0 +1,125 @@
1
+ import {
2
+ ComposerUncertainTransportError,
3
+ snapshotComposerReceipt,
4
+ snapshotComposerSubmission,
5
+ } from "@duskmoon-dev/components/chat";
6
+
7
+ export function requestId() {
8
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
9
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
10
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
11
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
12
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
13
+ return [
14
+ hex.slice(0, 4),
15
+ hex.slice(4, 6),
16
+ hex.slice(6, 8),
17
+ hex.slice(8, 10),
18
+ hex.slice(10),
19
+ ]
20
+ .map((part) => part.join(""))
21
+ .join("-");
22
+ }
23
+
24
+ // Compatible with the upstream session controller's uncertain-transport boundary.
25
+ export class ChatTransportError extends ComposerUncertainTransportError {
26
+ constructor(message, correlation, cause) {
27
+ super(message, { cause });
28
+ this.name = "ChatTransportError";
29
+ this.outcome = "unknown";
30
+ Object.assign(this, correlation);
31
+ }
32
+ }
33
+
34
+ export function unknownReceipt(snapshot, error) {
35
+ return Object.freeze({
36
+ protocolVersion: 1,
37
+ clientSubmissionId: snapshot.clientSubmissionId,
38
+ scope: snapshot.scope,
39
+ operation: snapshot.operation,
40
+ outcome: "unknown",
41
+ error: {
42
+ code: error.code || "uncertain_transport",
43
+ message: error.message,
44
+ ...(error.request_id
45
+ ? {
46
+ details: {
47
+ request_id: error.request_id,
48
+ instanceGeneration: error.instanceGeneration,
49
+ },
50
+ }
51
+ : {}),
52
+ },
53
+ });
54
+ }
55
+
56
+ // Reject binary handles before any JSON serialization, including the legacy path.
57
+ export function assertChatJSON(value, ancestors = new Set()) {
58
+ if (value == null || ["string", "boolean"].includes(typeof value)) return;
59
+ if (typeof value === "number" && Number.isFinite(value)) return;
60
+ if (typeof value !== "object" || ancestors.has(value))
61
+ throw new TypeError("Chat payload must contain portable JSON only");
62
+ if (
63
+ !Array.isArray(value) &&
64
+ Object.getPrototypeOf(value) !== Object.prototype &&
65
+ Object.getPrototypeOf(value) !== null
66
+ )
67
+ throw new TypeError(
68
+ "Upload File/Blob objects through an application adapter before submitting JSON",
69
+ );
70
+ ancestors.add(value);
71
+ for (const item of Object.values(value)) assertChatJSON(item, ancestors);
72
+ ancestors.delete(value);
73
+ }
74
+
75
+ export function submissionSnapshot(payload, state) {
76
+ if (state.protocolVersion !== 1)
77
+ throw new TypeError("Structured composer protocol is not enabled");
78
+ const { request_id, ...submission } = payload;
79
+ assertChatJSON(submission);
80
+ const snapshot = snapshotComposerSubmission(submission);
81
+ if (
82
+ snapshot.scope.contextKey !== state.scope.contextKey ||
83
+ snapshot.scope.conversationId !== state.scope.conversationId
84
+ )
85
+ throw new TypeError("Submission scope does not match this chat instance");
86
+ return snapshot;
87
+ }
88
+
89
+ export function structuredSnapshot(payload, state) {
90
+ const snapshot = submissionSnapshot(payload, state);
91
+ const caps = state.composer?.capabilities;
92
+ if (
93
+ caps?.protocolVersion !== 1 ||
94
+ !caps.documentVersions?.includes(snapshot.document.schemaVersion)
95
+ )
96
+ throw new TypeError("Unsupported composer document version");
97
+ if (
98
+ snapshot.operation === "command"
99
+ ? caps.commands !== true
100
+ : !caps.intents?.includes(snapshot.intent)
101
+ )
102
+ throw new TypeError("Unsupported composer operation or intent");
103
+ if (snapshot.attachments.length && caps.attachments !== true)
104
+ throw new TypeError("Attachments are not supported by this application");
105
+ return snapshot;
106
+ }
107
+
108
+ export function chatJSONKey(value) {
109
+ if (Array.isArray(value)) return `[${value.map(chatJSONKey).join(",")}]`;
110
+ if (value && typeof value === "object")
111
+ return `{${Object.keys(value)
112
+ .sort()
113
+ .map((key) => `${JSON.stringify(key)}:${chatJSONKey(value[key])}`)
114
+ .join(",")}}`;
115
+ return JSON.stringify(value);
116
+ }
117
+
118
+ export function correlatedReceipt(response, snapshot, correlation) {
119
+ if (
120
+ response?.request_id !== correlation.request_id ||
121
+ response?.instanceGeneration !== correlation.instanceGeneration
122
+ )
123
+ throw new TypeError("Chat response correlation mismatch");
124
+ return snapshotComposerReceipt(response.receipt, snapshot);
125
+ }