@turingfocus/chat-runtime 0.7.0 → 0.8.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.
@@ -1,9 +1,12 @@
1
- import { answerInteractionInputSchema, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, } from "@turingfocus/chat-protocol";
1
+ import { answerInteractionInputSchema, getTimelineItemKey, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, sendMessageInputSchema, } from "@turingfocus/chat-protocol";
2
2
  import { createGatewayNotificationQueue, } from "./gateway-notification-queue.js";
3
3
  import { cloneImmutable, deepEqual } from "./immutable.js";
4
4
  import { applySnapshotUpdate, applyRuntimeSnapshotError, createSnapshotState, rebaseSnapshotState, updateConversationId, updateSnapshotState, } from "./snapshot-state.js";
5
5
  import { SnapshotStore, } from "./snapshot-store.js";
6
- import { mergeHistoryTimeline } from "./timeline.js";
6
+ import { createTimelineState, mergeHistoryTimeline } from "./timeline.js";
7
+ import { ConversationCache, mergeCachedHistory, } from "./conversation-cache.js";
8
+ import { cachedDisplaySnapshot } from "./cache-record.js";
9
+ import { areComposerLongTextsValid, emptyComposerDraft, rebaseComposerLongTexts, resolveComposerDraftText, } from "./composer-draft.js";
7
10
  const runtimeFailure = (code, message, conversationId) => ({
8
11
  ok: false,
9
12
  error: cloneImmutable({
@@ -41,6 +44,19 @@ const runCleanup = async (cleanup, onError) => {
41
44
  */
42
45
  export class ChatClient {
43
46
  #gateway;
47
+ #cache;
48
+ #cacheListeners = new Set();
49
+ #cacheRequestId = 0;
50
+ #cacheReadOnly = false;
51
+ #cacheWriteScheduled = false;
52
+ #clearedSnapshot = null;
53
+ #cacheState = Object.freeze({
54
+ status: "idle",
55
+ source: "none",
56
+ freshness: "miss",
57
+ unavailableAttachments: 0,
58
+ storageError: false,
59
+ });
44
60
  #onUnhandledError;
45
61
  #snapshotStore;
46
62
  #disposed = false;
@@ -50,6 +66,10 @@ export class ChatClient {
50
66
  #generation = 0;
51
67
  #gatewaySubscription;
52
68
  #interactionAnswersInFlight = new Set();
69
+ #composerDrafts = new Map();
70
+ #composerListeners = new Set();
71
+ #composerSendsInFlight = new Map();
72
+ #messageSendsInFlight = new Map();
53
73
  #retiredInteractionIdentities = new Set();
54
74
  #supersededInteractionAnswers = new Set();
55
75
  #historyRequestId = 0;
@@ -61,12 +81,141 @@ export class ChatClient {
61
81
  #pendingHandoff;
62
82
  constructor(options) {
63
83
  this.#gateway = options.gateway;
84
+ this.#cache =
85
+ options.cache === false
86
+ ? undefined
87
+ : new ConversationCache({
88
+ ...options.cache,
89
+ onEvent: (event) => {
90
+ if (event.kind === "storage-error" ||
91
+ event.kind === "invalid-record" ||
92
+ event.kind === "write-conflict")
93
+ this.#setCacheState({
94
+ ...this.#cacheState,
95
+ storageError: true,
96
+ });
97
+ if (options.cache)
98
+ options.cache.onEvent?.(event);
99
+ },
100
+ }, (id) => {
101
+ if (this.getSnapshot()?.conversation.id !== id)
102
+ this.#discardComposerDraft(id);
103
+ });
104
+ if (this.#cache === undefined)
105
+ this.#cacheState = Object.freeze({
106
+ ...this.#cacheState,
107
+ status: "disabled",
108
+ });
64
109
  this.#onUnhandledError = options.onUnhandledError;
65
110
  this.#snapshotStore = new SnapshotStore({
66
111
  onListenerError: (cause) => {
67
112
  this.#reportUnhandledError({ cause, source: "listener" });
68
113
  },
69
114
  });
115
+ if (options.cache !== false && options.cache?.storage !== undefined) {
116
+ this.#snapshotStore.subscribe(() => this.#scheduleCacheWrite());
117
+ }
118
+ }
119
+ getCacheState() {
120
+ return this.#cacheState;
121
+ }
122
+ subscribeCacheState(listener) {
123
+ const isDisposed = () => this.#disposed;
124
+ let closed = this.#disposed;
125
+ if (!closed)
126
+ this.#cacheListeners.add(listener);
127
+ return {
128
+ get closed() {
129
+ return closed || isDisposed();
130
+ },
131
+ dispose: () => {
132
+ closed = true;
133
+ this.#cacheListeners.delete(listener);
134
+ },
135
+ };
136
+ }
137
+ async clearConversationCache(conversationId, options) {
138
+ if (this.#disposed)
139
+ return;
140
+ if (this.#cacheState.conversationId === conversationId)
141
+ this.cancelPendingConversationLoad();
142
+ if (this.getSnapshot()?.conversation.id === conversationId)
143
+ this.#clearedSnapshot = this.getSnapshot();
144
+ this.#discardComposerDraft(conversationId);
145
+ const clearing = this.#cache?.clear(conversationId, options);
146
+ if (this.getSnapshot()?.conversation.id === conversationId)
147
+ this.#clearCachedView();
148
+ if (this.#cacheState.conversationId === conversationId)
149
+ this.#setCacheState({
150
+ ...this.#cacheState,
151
+ source: "none",
152
+ freshness: "miss",
153
+ });
154
+ await clearing;
155
+ }
156
+ async clearCache(options) {
157
+ if (this.#disposed)
158
+ return;
159
+ this.cancelPendingConversationLoad();
160
+ this.#clearedSnapshot = this.getSnapshot();
161
+ for (const id of [...this.#composerDrafts.keys()])
162
+ this.#discardComposerDraft(id);
163
+ const clearing = this.#cache?.clear(undefined, options);
164
+ this.#clearCachedView();
165
+ this.#setCacheState({
166
+ status: this.#cache === undefined ? "disabled" : "idle",
167
+ source: "none",
168
+ freshness: "miss",
169
+ unavailableAttachments: 0,
170
+ storageError: this.#cache?.storageError ?? false,
171
+ });
172
+ await clearing;
173
+ }
174
+ #clearCachedView() {
175
+ const snapshot = this.getSnapshot();
176
+ if (!this.#cacheReadOnly || snapshot === null)
177
+ return;
178
+ // Clearing local content does not delete the server conversation or selection.
179
+ this.#snapshotStore.commit(createSnapshotState(cachedDisplaySnapshot({
180
+ ...snapshot,
181
+ timeline: [],
182
+ pageInfo: { hasPreviousPage: false },
183
+ })));
184
+ }
185
+ async flushCache(options) {
186
+ this.#captureCache(options);
187
+ await this.#cache?.flush();
188
+ }
189
+ #captureCache(options) {
190
+ const snapshot = this.getSnapshot();
191
+ if (this.#disposed ||
192
+ this.#cacheReadOnly ||
193
+ snapshot === null ||
194
+ snapshot === this.#clearedSnapshot)
195
+ return;
196
+ this.#cache?.put(snapshot, this.getComposerDraft(snapshot.conversation.id), options);
197
+ }
198
+ #scheduleCacheWrite() {
199
+ if (this.#cacheWriteScheduled || this.#disposed || this.#cacheReadOnly)
200
+ return;
201
+ this.#cacheWriteScheduled = true;
202
+ void Promise.resolve().then(() => {
203
+ this.#cacheWriteScheduled = false;
204
+ this.#captureCache({ deadlineAt: Date.now() + 5000 });
205
+ });
206
+ }
207
+ #setCacheState(state) {
208
+ if (this.#disposed || this.#cache === undefined)
209
+ return;
210
+ this.#cacheState = Object.freeze(state);
211
+ for (const listener of this.#cacheListeners) {
212
+ try {
213
+ listener();
214
+ }
215
+ catch (cause) {
216
+ this.#reportUnhandledError({ cause, source: "listener" });
217
+ }
218
+ }
70
219
  }
71
220
  get disposed() {
72
221
  return this.#disposed;
@@ -81,12 +230,124 @@ export class ChatClient {
81
230
  subscribeState(listener) {
82
231
  return this.#snapshotStore.subscribeState(listener);
83
232
  }
233
+ getComposerDraft(conversationId) {
234
+ const existing = this.#composerDrafts.get(conversationId);
235
+ if (existing !== undefined)
236
+ return existing;
237
+ const empty = emptyComposerDraft(conversationId);
238
+ if (!this.#disposed)
239
+ this.#composerDrafts.set(conversationId, empty);
240
+ return empty;
241
+ }
242
+ #discardComposerDraft(conversationId) {
243
+ this.#composerDrafts.delete(conversationId);
244
+ const empty = emptyComposerDraft(conversationId);
245
+ for (const listener of this.#composerListeners) {
246
+ try {
247
+ listener(empty);
248
+ }
249
+ catch (cause) {
250
+ this.#reportUnhandledError({ cause, source: "listener" });
251
+ }
252
+ }
253
+ }
254
+ setComposerDraft(input) {
255
+ if (this.#disposed)
256
+ return this.getComposerDraft(input.conversationId);
257
+ const current = this.getComposerDraft(input.conversationId);
258
+ const nextText = input.text ?? current.text;
259
+ const nextLongTexts = input.longTexts ??
260
+ rebaseComposerLongTexts(current.text, nextText, current.longTexts);
261
+ if (!areComposerLongTextsValid(nextText, nextLongTexts)) {
262
+ throw new TypeError("Composer long-text anchors are invalid");
263
+ }
264
+ const next = cloneImmutable({
265
+ conversationId: input.conversationId,
266
+ text: nextText,
267
+ attachments: input.attachments ?? current.attachments,
268
+ longTexts: nextLongTexts,
269
+ revision: current.revision + 1,
270
+ });
271
+ this.#composerDrafts.set(input.conversationId, next);
272
+ this.#cache?.updateDraft(input.conversationId, next, {
273
+ deadlineAt: Date.now() + 5000,
274
+ }, input.attachments === undefined);
275
+ this.#scheduleCacheWrite();
276
+ for (const listener of this.#composerListeners) {
277
+ try {
278
+ listener(next);
279
+ }
280
+ catch (cause) {
281
+ this.#reportUnhandledError({ cause, source: "listener" });
282
+ }
283
+ }
284
+ return next;
285
+ }
286
+ subscribeComposerDraft(listener) {
287
+ if (this.#disposed)
288
+ return { closed: true, dispose: () => undefined };
289
+ this.#composerListeners.add(listener);
290
+ let active = true;
291
+ return {
292
+ get closed() {
293
+ return !active;
294
+ },
295
+ dispose: () => {
296
+ if (!active)
297
+ return;
298
+ active = false;
299
+ this.#composerListeners.delete(listener);
300
+ },
301
+ };
302
+ }
303
+ async sendComposerDraft(input) {
304
+ const inFlightKey = input.clientMessageId === undefined
305
+ ? undefined
306
+ : `${input.conversationId.length}:${input.conversationId}:${input.clientMessageId}`;
307
+ if (inFlightKey !== undefined) {
308
+ const existing = this.#composerSendsInFlight.get(inFlightKey);
309
+ if (existing !== undefined)
310
+ return existing;
311
+ }
312
+ const draft = this.getComposerDraft(input.conversationId);
313
+ const operation = (async () => {
314
+ const result = await this.sendMessage({
315
+ conversationId: input.conversationId,
316
+ deadlineAt: input.deadlineAt,
317
+ text: resolveComposerDraftText(draft),
318
+ attachments: draft.attachments,
319
+ ...(input.clientMessageId === undefined
320
+ ? {}
321
+ : { clientMessageId: input.clientMessageId }),
322
+ });
323
+ if (result.ok && this.getComposerDraft(input.conversationId) === draft) {
324
+ this.setComposerDraft({
325
+ conversationId: input.conversationId,
326
+ text: "",
327
+ attachments: [],
328
+ longTexts: [],
329
+ });
330
+ }
331
+ return result;
332
+ })();
333
+ if (inFlightKey !== undefined) {
334
+ this.#composerSendsInFlight.set(inFlightKey, operation);
335
+ const clearInFlight = () => {
336
+ if (this.#composerSendsInFlight.get(inFlightKey) === operation) {
337
+ this.#composerSendsInFlight.delete(inFlightKey);
338
+ }
339
+ };
340
+ void operation.then(clearInFlight, clearInFlight);
341
+ }
342
+ return operation;
343
+ }
84
344
  /**
85
345
  * Retires the current top-level conversation load without changing an
86
346
  * already committed snapshot. Once a snapshot commits, this is a no-op and
87
347
  * that load completes successfully while transport cleanup settles.
88
348
  */
89
349
  cancelPendingConversationLoad() {
350
+ this.#cacheRequestId += 1;
90
351
  if (this.#disposed || this.#pendingLoadRequestId === undefined)
91
352
  return;
92
353
  this.#loadRequestId += 1;
@@ -192,6 +453,25 @@ export class ChatClient {
192
453
  result.value.deletedConversationId !== parsed.data.conversationId) {
193
454
  return runtimeFailure("validation", "Gateway deleted a different conversation", parsed.data.conversationId);
194
455
  }
456
+ const cacheClear = result.ok
457
+ ? this.#cache
458
+ ?.clear(parsed.data.conversationId, parsed.data)
459
+ .catch(() => undefined)
460
+ : undefined;
461
+ if (result.ok) {
462
+ if (this.#cacheState.conversationId === parsed.data.conversationId)
463
+ this.#cacheRequestId += 1;
464
+ this.#discardComposerDraft(parsed.data.conversationId);
465
+ const sendKeyPrefix = `${parsed.data.conversationId.length}:${parsed.data.conversationId}:`;
466
+ for (const key of this.#composerSendsInFlight.keys()) {
467
+ if (key.startsWith(sendKeyPrefix))
468
+ this.#composerSendsInFlight.delete(key);
469
+ }
470
+ for (const key of this.#messageSendsInFlight.keys()) {
471
+ if (key.startsWith(sendKeyPrefix))
472
+ this.#messageSendsInFlight.delete(key);
473
+ }
474
+ }
195
475
  if (result.ok &&
196
476
  this.#pendingLoadConversationId === parsed.data.conversationId) {
197
477
  this.#loadRequestId += 1;
@@ -220,9 +500,144 @@ export class ChatClient {
220
500
  this.#snapshotStore.clear();
221
501
  await this.#runSubscriptionCleanup(() => subscription?.dispose(parsed.data), parsed.data.conversationId);
222
502
  }
503
+ await cacheClear;
223
504
  return result;
224
505
  }
225
506
  async loadConversation(input) {
507
+ if (this.#cache === undefined ||
508
+ input.previousCursor !== undefined ||
509
+ this.#disposed)
510
+ return this.#loadConversationFromGateway(input);
511
+ if (isGatewayDeadlineExceeded(input))
512
+ return {
513
+ ok: false,
514
+ error: createGatewayDeadlineExceededError(input.conversationId),
515
+ };
516
+ this.cancelPendingConversationLoad();
517
+ const ticket = this.#cacheRequestId;
518
+ const epoch = this.#cache.epochFor(input.conversationId);
519
+ this.#captureCache(input);
520
+ let entry = this.#cache.peek(input.conversationId);
521
+ let source = entry === undefined ? "none" : "memory";
522
+ if (entry === undefined && this.#cache.hasStorage) {
523
+ entry = await this.#cache.restore(input.conversationId, input);
524
+ if (entry !== undefined)
525
+ source = "storage";
526
+ }
527
+ if (this.#disposed ||
528
+ ticket !== this.#cacheRequestId ||
529
+ epoch !== this.#cache.epochFor(input.conversationId))
530
+ return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
531
+ this.#setCacheState({
532
+ conversationId: input.conversationId,
533
+ status: "syncing",
534
+ source,
535
+ freshness: entry === undefined
536
+ ? this.#cache.wasExpired(input.conversationId)
537
+ ? "expired"
538
+ : "miss"
539
+ : this.#cache.freshness(entry.savedAt),
540
+ ...(entry === undefined ? {} : { savedAt: entry.savedAt }),
541
+ unavailableAttachments: (entry?.unavailableAttachments ?? 0) +
542
+ (entry?.attachmentKeys?.length ?? 0),
543
+ storageError: this.#cache.storageError,
544
+ });
545
+ if (this.#disposed ||
546
+ ticket !== this.#cacheRequestId ||
547
+ epoch !== this.#cache.epochFor(input.conversationId))
548
+ return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
549
+ if (entry !== undefined &&
550
+ (this.getSnapshot()?.conversation.id !== input.conversationId ||
551
+ this.#cacheReadOnly)) {
552
+ const oldSubscription = this.#gatewaySubscription;
553
+ this.#gatewaySubscription = undefined;
554
+ this.#generation += 1;
555
+ this.#conversationEpoch += 1;
556
+ this.#historyRequestId += 1;
557
+ this.#cacheReadOnly = true;
558
+ this.#snapshotStore.commit(createSnapshotState(cachedDisplaySnapshot(entry.snapshot)));
559
+ void this.#runSubscriptionCleanup(() => oldSubscription?.dispose(input), input.conversationId);
560
+ if (ticket === this.#cacheRequestId &&
561
+ epoch === this.#cache.epochFor(input.conversationId))
562
+ this.#restoreCachedDraft(entry, source, input, ticket);
563
+ }
564
+ if (this.#disposed ||
565
+ ticket !== this.#cacheRequestId ||
566
+ epoch !== this.#cache.epochFor(input.conversationId))
567
+ return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
568
+ const result = await this.#loadConversationFromGateway(input);
569
+ if (this.#disposed ||
570
+ ticket !== this.#cacheRequestId ||
571
+ epoch !== this.#cache.epochFor(input.conversationId))
572
+ return result;
573
+ if (result.ok) {
574
+ this.#cacheReadOnly = false;
575
+ this.#captureCache(input);
576
+ this.#setCacheState({
577
+ ...this.#cacheState,
578
+ status: "ready",
579
+ freshness: "fresh",
580
+ savedAt: this.#cache.now(),
581
+ storageError: this.#cache.storageError,
582
+ });
583
+ }
584
+ else {
585
+ this.#setCacheState({
586
+ ...this.#cacheState,
587
+ status: "error",
588
+ storageError: this.#cache.storageError,
589
+ });
590
+ if (result.error.code === "not-found" ||
591
+ result.error.code === "authorization" ||
592
+ result.error.code === "authentication") {
593
+ await this.clearConversationCache(input.conversationId, input).catch(() => undefined);
594
+ }
595
+ }
596
+ return result;
597
+ }
598
+ #restoreCachedDraft(entry, source, input, ticket) {
599
+ const existing = this.#composerDrafts.get(input.conversationId);
600
+ if (existing !== undefined && existing.revision > 0)
601
+ return;
602
+ const draft = this.setComposerDraft({
603
+ conversationId: input.conversationId,
604
+ text: entry.draft.text,
605
+ longTexts: entry.draft.longTexts,
606
+ ...(source === "memory" ? { attachments: entry.draft.attachments } : {}),
607
+ });
608
+ if (source !== "storage")
609
+ return;
610
+ const epoch = this.#cache.epochFor(input.conversationId);
611
+ this.#setCacheState({
612
+ ...this.#cacheState,
613
+ unavailableAttachments: (entry.attachmentKeys?.length ?? 0) +
614
+ (entry.unavailableAttachments ?? 0),
615
+ });
616
+ void this.#cache.restoreAttachments(entry, input).then((restored) => {
617
+ if (this.#disposed ||
618
+ epoch !== this.#cache.epochFor(input.conversationId) ||
619
+ this.getComposerDraft(input.conversationId).revision !== draft.revision)
620
+ return;
621
+ const validated = sendMessageInputSchema.safeParse({
622
+ ...input,
623
+ text: draft.text || " ",
624
+ attachments: restored.attachments,
625
+ });
626
+ this.setComposerDraft({
627
+ conversationId: input.conversationId,
628
+ attachments: validated.success ? restored.attachments : [],
629
+ });
630
+ const unavailable = restored.unavailable +
631
+ (validated.success ? 0 : restored.attachments.length);
632
+ this.#cache.setUnavailableAttachments(input.conversationId, unavailable, input);
633
+ if (ticket === this.#cacheRequestId)
634
+ this.#setCacheState({
635
+ ...this.#cacheState,
636
+ unavailableAttachments: unavailable,
637
+ });
638
+ });
639
+ }
640
+ async #loadConversationFromGateway(input) {
226
641
  if (this.#disposed) {
227
642
  return runtimeFailure("conflict", "ChatClient has already been disposed", input.conversationId);
228
643
  }
@@ -261,7 +676,10 @@ export class ChatClient {
261
676
  await this.#discardGatewaySubscription(subscribed, input);
262
677
  return runtimeFailure("conflict", "Conversation load was superseded", input.conversationId);
263
678
  }
264
- const loaded = await this.#gateway.loadConversation(input);
679
+ let loaded = await this.#gateway.loadConversation(input);
680
+ if (loaded.ok && loaded.value.conversation.id === input.conversationId) {
681
+ loaded = await this.#bridgeCachedHistory(input, loaded.value, requestId);
682
+ }
265
683
  if (!loaded.ok) {
266
684
  this.#clearPendingLoad(requestId);
267
685
  await this.#discardGatewaySubscription(subscribed, input);
@@ -310,9 +728,11 @@ export class ChatClient {
310
728
  this.#pendingLoadConversationId === input.conversationId
311
729
  ? this.#pendingLoadConversationOverride
312
730
  : undefined;
731
+ const cached = this.#cache?.peek(input.conversationId);
732
+ const loadedWithHistory = mergeCachedHistory(loaded.value, cached?.snapshot);
313
733
  const loadedState = createSnapshotState(conversationOverride === undefined
314
- ? loaded.value
315
- : { ...loaded.value, conversation: conversationOverride });
734
+ ? loadedWithHistory
735
+ : { ...loadedWithHistory, conversation: conversationOverride });
316
736
  const currentState = this.#snapshotStore.state;
317
737
  const handoff = this.#pendingHandoff;
318
738
  let nextState = handoff?.requestId === requestId && currentState !== null
@@ -327,6 +747,7 @@ export class ChatClient {
327
747
  nextState = this.#guardInteractionTransition(currentState, nextState);
328
748
  }
329
749
  this.#clearPendingLoad(requestId);
750
+ this.#cacheReadOnly = false;
330
751
  this.#snapshotStore.commit(nextState);
331
752
  if (!this.#disposed)
332
753
  notificationQueue.activate(generation);
@@ -347,6 +768,58 @@ export class ChatClient {
347
768
  this.#reportError(result.error, generation, "command");
348
769
  return result;
349
770
  }
771
+ async sendMessage(input) {
772
+ const parsed = sendMessageInputSchema.safeParse(input);
773
+ if (!parsed.success) {
774
+ return runtimeFailure("validation", "Message input is invalid", typeof input.conversationId === "string"
775
+ ? input.conversationId
776
+ : undefined);
777
+ }
778
+ const command = parsed.data;
779
+ const attachments = command.attachments ?? [];
780
+ if (attachments.length === 0) {
781
+ return this.sendText({
782
+ conversationId: command.conversationId,
783
+ deadlineAt: command.deadlineAt,
784
+ text: command.text ?? "",
785
+ ...(command.clientMessageId === undefined
786
+ ? {}
787
+ : { clientMessageId: command.clientMessageId }),
788
+ });
789
+ }
790
+ const state = this.#activeState(command.conversationId);
791
+ if (!state.ok)
792
+ return state;
793
+ if (!isGatewayOperationSupported(state.value.capabilities, "sendAttachments") ||
794
+ this.#gateway.sendMessage === undefined) {
795
+ return runtimeFailure("unsupported", "Attachment sending is unavailable", command.conversationId);
796
+ }
797
+ const inFlightKey = command.clientMessageId === undefined
798
+ ? undefined
799
+ : `${command.conversationId.length}:${command.conversationId}:${command.clientMessageId}`;
800
+ if (inFlightKey !== undefined) {
801
+ const existing = this.#messageSendsInFlight.get(inFlightKey);
802
+ if (existing !== undefined)
803
+ return existing;
804
+ }
805
+ const generation = this.#generation;
806
+ const operation = (async () => {
807
+ const result = await this.#gateway.sendMessage(command);
808
+ if (!result.ok && !this.#disposed)
809
+ this.#reportError(result.error, generation, "command");
810
+ return result;
811
+ })();
812
+ if (inFlightKey !== undefined) {
813
+ this.#messageSendsInFlight.set(inFlightKey, operation);
814
+ const clearInFlight = () => {
815
+ if (this.#messageSendsInFlight.get(inFlightKey) === operation) {
816
+ this.#messageSendsInFlight.delete(inFlightKey);
817
+ }
818
+ };
819
+ void operation.then(clearInFlight, clearInFlight);
820
+ }
821
+ return operation;
822
+ }
350
823
  async answerInteraction(input) {
351
824
  const parsed = answerInteractionInputSchema.safeParse(input);
352
825
  if (!parsed.success) {
@@ -445,6 +918,10 @@ export class ChatClient {
445
918
  dispose(options) {
446
919
  if (this.#disposePromise !== undefined)
447
920
  return this.#disposePromise;
921
+ this.#captureCache(options);
922
+ const cacheClosed = this.#cache?.close();
923
+ this.#cacheListeners.clear();
924
+ this.#cacheRequestId += 1;
448
925
  this.#disposed = true;
449
926
  this.#conversationEpoch += 1;
450
927
  this.#generation += 1;
@@ -452,6 +929,10 @@ export class ChatClient {
452
929
  this.#interactionAnswersInFlight.clear();
453
930
  this.#retiredInteractionIdentities.clear();
454
931
  this.#supersededInteractionAnswers.clear();
932
+ this.#composerSendsInFlight.clear();
933
+ this.#messageSendsInFlight.clear();
934
+ this.#composerDrafts.clear();
935
+ this.#composerListeners.clear();
455
936
  this.#loadRequestId += 1;
456
937
  this.#pendingLoadConversationId = undefined;
457
938
  this.#pendingLoadConversationOverride = undefined;
@@ -461,6 +942,7 @@ export class ChatClient {
461
942
  const subscription = this.#gatewaySubscription;
462
943
  this.#gatewaySubscription = undefined;
463
944
  this.#disposePromise = Promise.allSettled([
945
+ cacheClosed,
464
946
  Promise.resolve().then(() => subscription?.dispose(options)),
465
947
  Promise.resolve().then(() => this.#gateway.dispose(options)),
466
948
  ]).then((results) => {
@@ -473,6 +955,42 @@ export class ChatClient {
473
955
  });
474
956
  return this.#disposePromise;
475
957
  }
958
+ async #bridgeCachedHistory(input, snapshot, requestId) {
959
+ const cached = this.#cache?.peek(input.conversationId);
960
+ if (cached === undefined || cached.snapshot.timeline.length === 0)
961
+ return { ok: true, value: snapshot };
962
+ const keys = new Set(cached.snapshot.timeline.map(getTimelineItemKey));
963
+ const cursors = new Set();
964
+ let current = snapshot;
965
+ while (current.pageInfo.hasPreviousPage &&
966
+ !current.timeline.some((item) => keys.has(getTimelineItemKey(item)))) {
967
+ const cursor = current.pageInfo.previousCursor;
968
+ if (this.#disposed || requestId !== this.#loadRequestId)
969
+ return runtimeFailure("conflict", "Cache synchronization was superseded", input.conversationId);
970
+ if (isGatewayDeadlineExceeded(input))
971
+ return {
972
+ ok: false,
973
+ error: createGatewayDeadlineExceededError(input.conversationId),
974
+ };
975
+ if (cursor === undefined || cursors.has(cursor) || cursors.size >= 50)
976
+ return runtimeFailure("conflict", "Cache synchronization could not establish continuous history", input.conversationId);
977
+ cursors.add(cursor);
978
+ const page = await this.#gateway.loadConversation({
979
+ ...input,
980
+ previousCursor: cursor,
981
+ });
982
+ if (!page.ok)
983
+ return page;
984
+ if (page.value.conversation.id !== input.conversationId)
985
+ return runtimeFailure("validation", "Gateway returned history for a different conversation", input.conversationId);
986
+ current = {
987
+ ...current,
988
+ timeline: mergeHistoryTimeline(createTimelineState(current.timeline), page.value.timeline).items,
989
+ pageInfo: page.value.pageInfo,
990
+ };
991
+ }
992
+ return { ok: true, value: current };
993
+ }
476
994
  async #loadHistory(input) {
477
995
  const active = this.#activeState(input.conversationId);
478
996
  if (!active.ok)
@@ -534,6 +1052,8 @@ export class ChatClient {
534
1052
  if (this.#snapshotStore.state?.snapshot.conversation.id !== conversationId) {
535
1053
  return runtimeFailure("conflict", "Conversation is not active in this ChatClient", conversationId);
536
1054
  }
1055
+ if (this.#cacheReadOnly)
1056
+ return runtimeFailure("conflict", "Cached conversation is read-only until synchronization succeeds", conversationId);
537
1057
  const lifecycle = this.#snapshotStore.state.snapshot.lifecycle;
538
1058
  if (lifecycle !== undefined && !isChatLifecycleOperable(lifecycle)) {
539
1059
  return runtimeFailure(lifecycle.status === "auth-required"
@@ -624,7 +1144,15 @@ export class ChatClient {
624
1144
  return;
625
1145
  }
626
1146
  const state = this.#snapshotStore.latestState();
627
- this.#snapshotStore.commit(this.#guardInteractionTransition(state, applySnapshotUpdate(state, update)));
1147
+ const prepared = this.#cache !== undefined &&
1148
+ update.kind === "snapshot.replace" &&
1149
+ update.snapshot.conversation.id === state.snapshot.conversation.id
1150
+ ? {
1151
+ ...update,
1152
+ snapshot: mergeCachedHistory(update.snapshot, state.snapshot),
1153
+ }
1154
+ : update;
1155
+ this.#snapshotStore.commit(this.#guardInteractionTransition(state, applySnapshotUpdate(state, prepared)));
628
1156
  }
629
1157
  #guardInteractionTransition(current, candidate) {
630
1158
  const currentInteraction = current.snapshot.pendingInteraction;