@turingfocus/chat-gateway-tfrobot 0.6.0 → 0.8.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.
Files changed (45) hide show
  1. package/dist/attachment-uploader.d.ts +16 -0
  2. package/dist/attachment-uploader.d.ts.map +1 -0
  3. package/dist/attachment-uploader.js +63 -0
  4. package/dist/attachment-uploader.js.map +1 -0
  5. package/dist/dto.d.ts +14 -1
  6. package/dist/dto.d.ts.map +1 -1
  7. package/dist/dto.js +43 -6
  8. package/dist/dto.js.map +1 -1
  9. package/dist/gateway.d.ts +4 -1
  10. package/dist/gateway.d.ts.map +1 -1
  11. package/dist/gateway.js +317 -19
  12. package/dist/gateway.js.map +1 -1
  13. package/dist/http.d.ts +6 -2
  14. package/dist/http.d.ts.map +1 -1
  15. package/dist/http.js +52 -40
  16. package/dist/http.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/mapper.d.ts +1 -0
  22. package/dist/mapper.d.ts.map +1 -1
  23. package/dist/mapper.js +145 -8
  24. package/dist/mapper.js.map +1 -1
  25. package/dist/outbound-message.d.ts +7 -0
  26. package/dist/outbound-message.d.ts.map +1 -0
  27. package/dist/outbound-message.js +91 -0
  28. package/dist/outbound-message.js.map +1 -0
  29. package/dist/presentation.d.ts +5 -0
  30. package/dist/presentation.d.ts.map +1 -0
  31. package/dist/presentation.js +102 -0
  32. package/dist/presentation.js.map +1 -0
  33. package/dist/redaction.d.ts +4 -0
  34. package/dist/redaction.d.ts.map +1 -1
  35. package/dist/redaction.js +75 -24
  36. package/dist/redaction.js.map +1 -1
  37. package/dist/socket.d.ts +21 -3
  38. package/dist/socket.d.ts.map +1 -1
  39. package/dist/socket.js +317 -48
  40. package/dist/socket.js.map +1 -1
  41. package/dist/types.d.ts +33 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +29 -0
  44. package/dist/types.js.map +1 -1
  45. package/package.json +2 -2
package/dist/socket.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { io } from "socket.io-client";
2
- import { chatErrorSchema, chatUpdateSchema, createGatewayDeadlineExceededError, isGatewayDeadlineExceeded, } from "@turingfocus/chat-protocol";
2
+ import { chatErrorSchema, chatUpdateSchema, createGatewayDeadlineExceededError, getTimelineItemKey, isGatewayDeadlineExceeded, } from "@turingfocus/chat-protocol";
3
3
  import { awaitBounded } from "./bounded.js";
4
4
  import { chatErrorEventDtoSchema, eventDtoSchema, messageDtoSchema, socketProtocolErrorDtoSchema, stateChangedDtoSchema, statusDtoSchema, } from "./dto.js";
5
5
  import { mapEventUpdate, mapMessageUpdate, mapRun, mapUnknownSocketEvent, } from "./mapper.js";
6
- import { sanitizeCredentialError, sanitizeCredentialRaw, sanitizeCredentialText, } from "./redaction.js";
7
- import { isValidTFRobotSession } from "./types.js";
6
+ import { sanitizeCredentialError, sanitizeCredentialPayload, sanitizeCredentialRaw, sanitizeCredentialText, TransportPayloadError, } from "./redaction.js";
7
+ import { isValidTFRobotSession, resolveTFRobotServerProfile } from "./types.js";
8
8
  const KNOWN_EVENTS = new Set([
9
9
  "chat_error",
10
10
  "chat_event",
@@ -18,17 +18,21 @@ const KNOWN_EVENTS = new Set([
18
18
  const MAX_TIMER_DELAY = 2_147_483_647;
19
19
  const RECONNECT_AUTH_TIMEOUT_MS = 10_000;
20
20
  const RECONNECT_JOIN_TIMEOUT_MS = 10_000;
21
- const parseJoinAcknowledgement = (value, reconnect) => {
21
+ const parseJoinAcknowledgement = (value, reconnect, acceptEmpty) => {
22
22
  if (value === undefined) {
23
23
  return {
24
- accepted: reconnect,
24
+ accepted: reconnect || acceptEmpty,
25
+ empty: true,
25
26
  recoveryComplete: false,
26
- ...(reconnect ? {} : { rejectionCode: "validation" }),
27
+ ...(reconnect || acceptEmpty
28
+ ? {}
29
+ : { rejectionCode: "validation" }),
27
30
  };
28
31
  }
29
32
  if (value === null) {
30
33
  return {
31
34
  accepted: false,
35
+ empty: false,
32
36
  recoveryComplete: false,
33
37
  rejectionCode: "validation",
34
38
  };
@@ -36,16 +40,18 @@ const parseJoinAcknowledgement = (value, reconnect) => {
36
40
  if (value === false) {
37
41
  return {
38
42
  accepted: false,
43
+ empty: false,
39
44
  recoveryComplete: false,
40
45
  rejectionCode: "validation",
41
46
  };
42
47
  }
43
48
  if (value === true) {
44
- return { accepted: true, recoveryComplete: !reconnect };
49
+ return { accepted: true, empty: false, recoveryComplete: !reconnect };
45
50
  }
46
51
  if (typeof value !== "object") {
47
52
  return {
48
53
  accepted: false,
54
+ empty: false,
49
55
  recoveryComplete: false,
50
56
  rejectionCode: "validation",
51
57
  };
@@ -120,6 +126,7 @@ const parseJoinAcknowledgement = (value, reconnect) => {
120
126
  const authorizationRejected = statuses.some(({ normalized, numeric }) => numeric === 403 || normalized === "forbidden") || normalizedError === "forbidden";
121
127
  return {
122
128
  accepted,
129
+ empty: false,
123
130
  ...(authenticationRejected
124
131
  ? { rejectionCode: "authentication" }
125
132
  : authorizationRejected
@@ -218,27 +225,65 @@ export const createSocketIoFactoryWith = (connect) => ({ getAuth, namespaceUrl,
218
225
  },
219
226
  });
220
227
  export const createSocketIoFactory = createSocketIoFactoryWith(io);
228
+ const MAX_RECOVERY_IDENTITIES = 10_000;
229
+ const rememberBoundedSetValue = (values, value) => {
230
+ values.delete(value);
231
+ values.add(value);
232
+ while (values.size > MAX_RECOVERY_IDENTITIES) {
233
+ const oldest = values.values().next().value;
234
+ if (oldest === undefined)
235
+ break;
236
+ values.delete(oldest);
237
+ }
238
+ };
239
+ const rememberBoundedMapValue = (values, key, value) => {
240
+ values.delete(key);
241
+ values.set(key, value);
242
+ while (values.size > MAX_RECOVERY_IDENTITIES) {
243
+ const oldest = values.keys().next().value;
244
+ if (oldest === undefined)
245
+ break;
246
+ values.delete(oldest);
247
+ }
248
+ };
249
+ export const recoveryIdentityOfUpdate = (update) => {
250
+ if (update.kind === "timeline.upsert") {
251
+ return getTimelineItemKey(update.item);
252
+ }
253
+ if (update.kind === "event.transition.upsert") {
254
+ return `event:${update.event.id}:transition:${update.event.transition.id}`;
255
+ }
256
+ return undefined;
257
+ };
258
+ const recoveryIdentitiesOfSnapshot = (snapshot) => snapshot.timeline.flatMap((item) => item.kind === "agent-event"
259
+ ? item.transitions.map((transition) => `event:${item.id}:transition:${transition.id}`)
260
+ : [getTimelineItemKey(item)]);
221
261
  export class TFRobotSocketClient {
222
262
  #disposed = false;
223
- #establishmentAbort;
224
263
  #establishing;
264
+ #pendingEstablishment;
225
265
  #factory;
226
266
  #namespaceUrl;
227
267
  #now;
228
268
  #options;
229
269
  #path;
270
+ #profile;
271
+ #currentServerRestLoader;
230
272
  #reconnectStatusLoader;
273
+ #recoveryCheckpoints = new Map();
231
274
  #runs = new Map();
232
275
  #subscriptions = new Set();
233
276
  #subscriptionGeneration = 0;
234
- constructor(options, reconnectStatusLoader) {
277
+ constructor(options, reconnectStatusLoader, currentServerRestLoader) {
235
278
  this.#options = options;
236
279
  this.#factory = options.socketFactory ?? createSocketIoFactory;
237
280
  this.#path = options.socketPath ?? "/socket.io";
238
281
  this.#now = options.now ?? Date.now;
282
+ this.#profile = resolveTFRobotServerProfile(options.serverProfile);
239
283
  this.#namespaceUrl =
240
284
  options.socketNamespaceUrl ?? this.#defaultNamespaceUrl(options.baseUrl);
241
285
  this.#reconnectStatusLoader = reconnectStatusLoader;
286
+ this.#currentServerRestLoader = currentServerRestLoader;
242
287
  }
243
288
  async subscribe(conversationId, options, observer) {
244
289
  if (this.#disposed) {
@@ -257,14 +302,25 @@ export class TFRobotSocketClient {
257
302
  const subscriptionId = `tfrobot-subscription-${generation}`;
258
303
  this.#cancelEstablishment();
259
304
  const establishmentAbort = new AbortController();
260
- this.#establishmentAbort = establishmentAbort;
261
- const authOutcome = await awaitBounded(() => this.#getSocketAuth(conversationId, "connect"), {
305
+ const pendingEstablishment = {
306
+ abort: establishmentAbort,
307
+ conversationId,
308
+ generation,
309
+ };
310
+ this.#pendingEstablishment = pendingEstablishment;
311
+ const clearPendingEstablishment = () => {
312
+ if (this.#pendingEstablishment === pendingEstablishment) {
313
+ this.#pendingEstablishment = undefined;
314
+ }
315
+ };
316
+ const authOutcome = await awaitBounded(() => this.#getSocketSession(conversationId, "connect"), {
262
317
  deadlineAt: options.deadlineAt,
263
318
  now: this.#now,
264
319
  signal: establishmentAbort.signal,
265
320
  });
266
321
  switch (authOutcome.kind) {
267
322
  case "aborted": {
323
+ clearPendingEstablishment();
268
324
  return {
269
325
  ok: false,
270
326
  error: this.#error("conflict", this.#disposed
@@ -273,12 +329,14 @@ export class TFRobotSocketClient {
273
329
  };
274
330
  }
275
331
  case "deadline": {
332
+ clearPendingEstablishment();
276
333
  return {
277
334
  ok: false,
278
335
  error: createGatewayDeadlineExceededError(),
279
336
  };
280
337
  }
281
338
  case "error": {
339
+ clearPendingEstablishment();
282
340
  return {
283
341
  ok: false,
284
342
  error: this.#error("authentication", "Unable to obtain a TFRobot Socket session", true, undefined),
@@ -289,14 +347,43 @@ export class TFRobotSocketClient {
289
347
  }
290
348
  }
291
349
  if (this.#disposed || establishmentAbort.signal.aborted) {
350
+ clearPendingEstablishment();
292
351
  return {
293
352
  ok: false,
294
353
  error: this.#error("conflict", "TFRobot Gateway was disposed during subscription", false, undefined),
295
354
  };
296
355
  }
297
- const credentialValues = new Set(Object.values(authOutcome.value));
356
+ const initialSession = authOutcome.value;
357
+ if (this.#profile.kind === "current-server") {
358
+ const preflight = await this.#currentServerRestLoader({
359
+ checkpoint: new Set(),
360
+ conversationId,
361
+ deadlineAt: options.deadlineAt,
362
+ limits: {
363
+ ...this.#profile.rebase,
364
+ maxItems: this.#profile.rebase.pageSize,
365
+ maxPages: 1,
366
+ },
367
+ session: initialSession,
368
+ signal: establishmentAbort.signal,
369
+ });
370
+ if (this.#disposed || establishmentAbort.signal.aborted) {
371
+ clearPendingEstablishment();
372
+ return {
373
+ ok: false,
374
+ error: this.#error("conflict", "TFRobot subscription was replaced during REST preflight", false, undefined),
375
+ };
376
+ }
377
+ if (!preflight.ok) {
378
+ clearPendingEstablishment();
379
+ return preflight;
380
+ }
381
+ }
382
+ const initialAuth = authOf(initialSession);
383
+ const credentialValues = new Set(Object.values(initialAuth));
298
384
  const errorConversationId = () => sanitizeCredentialText(conversationId, credentialValues);
299
- let firstAuth = authOutcome.value;
385
+ let firstAuth = initialAuth;
386
+ let currentServerSession = initialSession;
300
387
  let authenticationFailure;
301
388
  const connectionAbort = new AbortController();
302
389
  let socket;
@@ -313,20 +400,22 @@ export class TFRobotSocketClient {
313
400
  firstAuth = undefined;
314
401
  return auth;
315
402
  }
316
- const reconnectAuth = await awaitBounded(() => this.#getSocketAuth(conversationId, "reconnect"), {
403
+ const reconnectSession = await awaitBounded(() => this.#getSocketSession(conversationId, "reconnect"), {
317
404
  deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
318
405
  now: this.#now,
319
406
  signal: connectionAbort.signal,
320
407
  });
321
- if (reconnectAuth.kind === "value") {
322
- for (const value of Object.values(reconnectAuth.value)) {
408
+ if (reconnectSession.kind === "value") {
409
+ currentServerSession = reconnectSession.value;
410
+ const reconnectAuth = authOf(reconnectSession.value);
411
+ for (const value of Object.values(reconnectAuth)) {
323
412
  credentialValues.add(value);
324
413
  }
325
- return reconnectAuth.value;
414
+ return reconnectAuth;
326
415
  }
327
- const reason = reconnectAuth.kind === "error"
328
- ? reconnectAuth.reason
329
- : new Error(reconnectAuth.kind === "deadline"
416
+ const reason = reconnectSession.kind === "error"
417
+ ? reconnectSession.reason
418
+ : new Error(reconnectSession.kind === "deadline"
330
419
  ? "TFRobot Socket session refresh exceeded its deadline"
331
420
  : "TFRobot Socket session refresh was cancelled");
332
421
  authenticationFailure = reason;
@@ -336,6 +425,7 @@ export class TFRobotSocketClient {
336
425
  }
337
426
  catch (reason) {
338
427
  connectionAbort.abort();
428
+ clearPendingEstablishment();
339
429
  return {
340
430
  ok: false,
341
431
  error: this.#transportError(reason, "Unable to create the TFRobot Socket transport", conversationId, credentialValues),
@@ -424,14 +514,7 @@ export class TFRobotSocketClient {
424
514
  pendingNotifications.push({ kind: "error", error });
425
515
  diagnose(error);
426
516
  };
427
- const sanitizePayload = (payload) => {
428
- try {
429
- return sanitizeCredentialRaw(payload, credentialValues);
430
- }
431
- catch {
432
- return undefined;
433
- }
434
- };
517
+ const sanitizePayload = (payload) => sanitizeCredentialPayload(payload, credentialValues);
435
518
  const next = (update) => {
436
519
  if (!active.active)
437
520
  return;
@@ -458,10 +541,20 @@ export class TFRobotSocketClient {
458
541
  };
459
542
  publishUpdate = next;
460
543
  const dispatchRealtime = (action) => {
544
+ const guardedAction = () => {
545
+ try {
546
+ action();
547
+ }
548
+ catch (reason) {
549
+ if (!(reason instanceof TransportPayloadError))
550
+ throw reason;
551
+ report(this.#error("validation", reason.message, false, errorConversationId()));
552
+ }
553
+ };
461
554
  if (active.acceptingEvents)
462
- action();
555
+ guardedAction();
463
556
  else if (joinPending)
464
- pendingRealtimeActions.push(action);
557
+ pendingRealtimeActions.push(guardedAction);
465
558
  };
466
559
  const flushRealtime = () => {
467
560
  for (const action of pendingRealtimeActions.splice(0))
@@ -496,12 +589,18 @@ export class TFRobotSocketClient {
496
589
  ? {}
497
590
  : {
498
591
  recoveryComplete: recovery.complete,
592
+ ...(recovery.assurance === undefined
593
+ ? {}
594
+ : { recoveryAssurance: recovery.assurance }),
499
595
  ...(recovery.cursor === undefined
500
596
  ? {}
501
597
  : { recoveryCursor: recovery.cursor }),
502
598
  ...(recovery.reason === undefined
503
599
  ? {}
504
600
  : { recoveryReason: recovery.reason }),
601
+ ...(recovery.source === undefined
602
+ ? {}
603
+ : { recoverySource: recovery.source }),
505
604
  }),
506
605
  })).catch(() => undefined);
507
606
  }
@@ -529,12 +628,80 @@ export class TFRobotSocketClient {
529
628
  try {
530
629
  update = map();
531
630
  }
532
- catch {
533
- report(this.#error("validation", invalidMessage, false, errorConversationId()));
631
+ catch (reason) {
632
+ report(this.#error("validation", reason instanceof TransportPayloadError
633
+ ? reason.message
634
+ : invalidMessage, false, errorConversationId()));
534
635
  return;
535
636
  }
637
+ const recoveryIdentity = recoveryIdentityOfUpdate(update);
638
+ if (recoveryIdentity !== undefined) {
639
+ active.realtimeRevision += 1;
640
+ rememberBoundedMapValue(active.realtimeIdentityRevisions, recoveryIdentity, active.realtimeRevision);
641
+ const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
642
+ rememberBoundedSetValue(checkpoint, recoveryIdentity);
643
+ this.#recoveryCheckpoints.set(conversationId, checkpoint);
644
+ }
536
645
  next(update);
537
646
  };
647
+ const rebaseCurrentServer = async (recoveryRevision, runRevision, session, checkpoint, realtimeRevision) => {
648
+ if (this.#profile.kind !== "current-server") {
649
+ return { kind: "failure" };
650
+ }
651
+ const result = await this.#currentServerRestLoader({
652
+ checkpoint,
653
+ conversationId,
654
+ deadlineAt: this.#now() + this.#profile.rebase.deadlineMs,
655
+ limits: this.#profile.rebase,
656
+ session,
657
+ signal: connectionAbort.signal,
658
+ });
659
+ if (!active.active ||
660
+ !this.#subscriptions.has(active) ||
661
+ !active.socket.connected ||
662
+ active.recoveryRevision !== recoveryRevision) {
663
+ return { kind: "failure" };
664
+ }
665
+ if (!result.ok) {
666
+ const error = sanitizeCredentialError(result.error, credentialValues);
667
+ if (error.code === "authentication" || error.code === "authorization") {
668
+ report(error, "authentication");
669
+ return { kind: "auth-required" };
670
+ }
671
+ report(error, "recovery");
672
+ return { kind: "failure" };
673
+ }
674
+ const remembered = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
675
+ for (const update of result.value.updates) {
676
+ const identity = recoveryIdentityOfUpdate(update);
677
+ if (identity !== undefined &&
678
+ (active.realtimeIdentityRevisions.get(identity) ?? 0) >
679
+ realtimeRevision) {
680
+ continue;
681
+ }
682
+ next(update);
683
+ if (identity !== undefined)
684
+ rememberBoundedSetValue(remembered, identity);
685
+ }
686
+ this.#recoveryCheckpoints.set(conversationId, remembered);
687
+ if (active.runRevision === runRevision) {
688
+ const previous = this.#runs.get(conversationId);
689
+ this.#runs.set(conversationId, result.value.run);
690
+ if (!sameRun(previous, result.value.run)) {
691
+ next(chatUpdateSchema.parse({
692
+ kind: "run.replace",
693
+ conversationId: errorConversationId(),
694
+ run: result.value.run,
695
+ }));
696
+ }
697
+ }
698
+ const reason = result.value.checkpointReached
699
+ ? "rest-rebase-reached-checkpoint"
700
+ : result.value.boundedBy === undefined
701
+ ? "rest-rebase-history-exhausted"
702
+ : `rest-rebase-${result.value.boundedBy}`;
703
+ return { kind: "success", reason };
704
+ };
538
705
  let settleEstablishment;
539
706
  add("connect", () => {
540
707
  if (!active.active)
@@ -563,6 +730,8 @@ export class TFRobotSocketClient {
563
730
  const reconnect = subscriptionEstablished;
564
731
  if (reconnect)
565
732
  active.reconnectAttempt += 1;
733
+ const rebaseCheckpoint = new Set(this.#recoveryCheckpoints.get(conversationId) ?? []);
734
+ const rebaseRealtimeRevision = active.realtimeRevision;
566
735
  const recoveryRevision = ++active.recoveryRevision;
567
736
  const runRevision = active.runRevision;
568
737
  const joinStartedAt = this.#now();
@@ -589,11 +758,12 @@ export class TFRobotSocketClient {
589
758
  const rawAcknowledgement = acknowledgementArguments[0];
590
759
  acknowledgement = parseJoinAcknowledgement(rawAcknowledgement === undefined || rawAcknowledgement === true
591
760
  ? rawAcknowledgement
592
- : sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect);
761
+ : sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect, this.#profile.kind === "current-server");
593
762
  }
594
763
  catch {
595
764
  acknowledgement = {
596
765
  accepted: false,
766
+ empty: false,
597
767
  recoveryComplete: false,
598
768
  rejectionCode: "validation",
599
769
  message: "Invalid TFRobot conversation subscription acknowledgement",
@@ -661,12 +831,24 @@ export class TFRobotSocketClient {
661
831
  });
662
832
  resolveError("connection");
663
833
  resolveError("authentication");
664
- lifecycle("active", {
665
- complete: true,
666
- ...(acknowledgement.cursor === undefined
667
- ? {}
668
- : { cursor: acknowledgement.cursor }),
669
- }, this.#now() - joinStartedAt);
834
+ if (this.#profile.kind === "current-server" &&
835
+ acknowledgement.empty) {
836
+ lifecycle("degraded", {
837
+ assurance: "best-effort",
838
+ complete: false,
839
+ reason: "initial-rest-preflight",
840
+ source: "rest-rebase",
841
+ }, this.#now() - joinStartedAt);
842
+ }
843
+ else {
844
+ lifecycle("active", {
845
+ complete: true,
846
+ ...(acknowledgement.cursor === undefined
847
+ ? {}
848
+ : { cursor: acknowledgement.cursor }),
849
+ }, this.#now() - joinStartedAt);
850
+ }
851
+ currentServerSession = undefined;
670
852
  active.acceptingEvents = true;
671
853
  flushRealtime();
672
854
  return;
@@ -684,6 +866,48 @@ export class TFRobotSocketClient {
684
866
  }, joinLatencyMs);
685
867
  active.acceptingEvents = true;
686
868
  flushRealtime();
869
+ if (this.#profile.kind === "current-server" &&
870
+ !acknowledgement.recoveryComplete) {
871
+ const recoverySession = currentServerSession;
872
+ currentServerSession = undefined;
873
+ if (recoverySession === undefined) {
874
+ report(this.#error("authentication", "TFRobot reconnect did not provide a session for REST rebase", true, errorConversationId()), "recovery");
875
+ return;
876
+ }
877
+ void rebaseCurrentServer(recoveryRevision, runRevision, recoverySession, rebaseCheckpoint, rebaseRealtimeRevision)
878
+ .then((outcome) => {
879
+ if (!active.active ||
880
+ !this.#subscriptions.has(active) ||
881
+ !active.socket.connected ||
882
+ active.recoveryRevision !== recoveryRevision) {
883
+ return;
884
+ }
885
+ if (outcome.kind === "auth-required") {
886
+ enterAuthRequired();
887
+ return;
888
+ }
889
+ if (outcome.kind !== "success")
890
+ return;
891
+ resolveError("connection");
892
+ resolveError("recovery");
893
+ resolveError("authentication");
894
+ active.manualReconnectAttempted = false;
895
+ active.manualReconnectPending = false;
896
+ active.authRequired = false;
897
+ active.terminal = false;
898
+ lifecycle("degraded", {
899
+ assurance: "best-effort",
900
+ complete: false,
901
+ reason: outcome.reason,
902
+ source: "rest-rebase",
903
+ }, joinLatencyMs);
904
+ })
905
+ .catch(() => {
906
+ report(this.#error("unknown", "TFRobot REST rebase failed unexpectedly", true, errorConversationId()), "recovery");
907
+ });
908
+ return;
909
+ }
910
+ currentServerSession = undefined;
687
911
  void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report)
688
912
  .then((outcome) => {
689
913
  if (outcome === "auth-required") {
@@ -949,7 +1173,20 @@ export class TFRobotSocketClient {
949
1173
  return;
950
1174
  if (belongsToForeignConversation(payload, conversationId))
951
1175
  return;
952
- mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), sanitizePayload(payload), errorConversationId(), this.#now()));
1176
+ // An unknown event needs only its name/time to remain visible. Its
1177
+ // optional payload must not turn a safe fallback into a stream error.
1178
+ let safePayload;
1179
+ if (payload !== undefined) {
1180
+ try {
1181
+ safePayload = sanitizePayload(payload);
1182
+ }
1183
+ catch (reason) {
1184
+ diagnose(this.#error("validation", reason instanceof TransportPayloadError
1185
+ ? reason.message
1186
+ : "Unknown TFRobot Socket payload could not be retained", false, errorConversationId()));
1187
+ }
1188
+ }
1189
+ mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), safePayload, errorConversationId(), this.#now()));
953
1190
  });
954
1191
  };
955
1192
  if (setupFailure === undefined) {
@@ -987,6 +1224,7 @@ export class TFRobotSocketClient {
987
1224
  // Adapter ownership is cleared even if an injected transport misbehaves.
988
1225
  }
989
1226
  firstAuth = undefined;
1227
+ currentServerSession = undefined;
990
1228
  credentialValues.clear();
991
1229
  pendingNotifications.length = 0;
992
1230
  pendingRealtimeActions.length = 0;
@@ -994,6 +1232,7 @@ export class TFRobotSocketClient {
994
1232
  if (setupFailure !== undefined) {
995
1233
  const error = this.#transportError(setupFailure.reason, "Unable to configure the TFRobot Socket transport", conversationId, credentialValues);
996
1234
  cleanup();
1235
+ clearPendingEstablishment();
997
1236
  return {
998
1237
  ok: false,
999
1238
  error,
@@ -1016,6 +1255,8 @@ export class TFRobotSocketClient {
1016
1255
  manualReconnectPending: false,
1017
1256
  observer,
1018
1257
  reconnectAttempt: 0,
1258
+ realtimeIdentityRevisions: new Map(),
1259
+ realtimeRevision: 0,
1019
1260
  recoveryRevision: 0,
1020
1261
  runRevision: 0,
1021
1262
  socket,
@@ -1031,9 +1272,7 @@ export class TFRobotSocketClient {
1031
1272
  return;
1032
1273
  settled = true;
1033
1274
  settleEstablishment = undefined;
1034
- if (this.#establishmentAbort === establishmentAbort) {
1035
- this.#establishmentAbort = undefined;
1036
- }
1275
+ clearPendingEstablishment();
1037
1276
  if (this.#establishing === active)
1038
1277
  this.#establishing = undefined;
1039
1278
  clearTimeout(timeout);
@@ -1087,16 +1326,46 @@ export class TFRobotSocketClient {
1087
1326
  subscription.cleanup();
1088
1327
  }
1089
1328
  this.#subscriptions.clear();
1329
+ this.#recoveryCheckpoints.clear();
1090
1330
  this.#runs.clear();
1091
1331
  }
1332
+ rememberSnapshot(snapshot) {
1333
+ if (this.#disposed)
1334
+ return;
1335
+ const conversationId = snapshot.conversation.id;
1336
+ this.#runs.set(conversationId, snapshot.run);
1337
+ const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
1338
+ for (const identity of recoveryIdentitiesOfSnapshot(snapshot)) {
1339
+ rememberBoundedSetValue(checkpoint, identity);
1340
+ }
1341
+ this.#recoveryCheckpoints.set(conversationId, checkpoint);
1342
+ }
1092
1343
  rememberRun(conversationId, run) {
1093
1344
  if (this.#disposed)
1094
1345
  return;
1095
1346
  this.#runs.set(conversationId, run);
1096
1347
  }
1348
+ forgetConversation(conversationId) {
1349
+ if (this.#disposed)
1350
+ return;
1351
+ if (this.#pendingEstablishment?.conversationId === conversationId ||
1352
+ this.#establishing?.conversationId === conversationId) {
1353
+ this.#cancelEstablishment();
1354
+ }
1355
+ for (const subscription of [...this.#subscriptions]) {
1356
+ if (subscription.conversationId !== conversationId)
1357
+ continue;
1358
+ subscription.active = false;
1359
+ subscription.recoveryRevision += 1;
1360
+ subscription.cleanup();
1361
+ this.#subscriptions.delete(subscription);
1362
+ }
1363
+ this.#recoveryCheckpoints.delete(conversationId);
1364
+ this.#runs.delete(conversationId);
1365
+ }
1097
1366
  #cancelEstablishment() {
1098
- this.#establishmentAbort?.abort();
1099
- this.#establishmentAbort = undefined;
1367
+ this.#pendingEstablishment?.abort.abort();
1368
+ this.#pendingEstablishment = undefined;
1100
1369
  this.#establishing?.cancelEstablishment();
1101
1370
  this.#establishing = undefined;
1102
1371
  }
@@ -1107,7 +1376,7 @@ export class TFRobotSocketClient {
1107
1376
  url.hash = "";
1108
1377
  return url.toString().replace(/\/$/u, "");
1109
1378
  }
1110
- async #getSocketAuth(conversationId, purpose) {
1379
+ async #getSocketSession(conversationId, purpose) {
1111
1380
  const session = await this.#options.sessionProvider.getSession({
1112
1381
  purpose,
1113
1382
  operation: "subscribe",
@@ -1116,7 +1385,7 @@ export class TFRobotSocketClient {
1116
1385
  if (!isValidTFRobotSession(session)) {
1117
1386
  throw new TypeError("SessionProvider returned invalid TFRobot credentials");
1118
1387
  }
1119
- return authOf(session);
1388
+ return session;
1120
1389
  }
1121
1390
  async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report) {
1122
1391
  const result = await this.#reconnectStatusLoader(conversationId);
@@ -1143,7 +1412,7 @@ export class TFRobotSocketClient {
1143
1412
  }
1144
1413
  try {
1145
1414
  const updateConversationId = sanitizeCredentialText(conversationId, credentialValues);
1146
- const safeStatus = statusDtoSchema.parse(sanitizeCredentialRaw(result.value, credentialValues));
1415
+ const safeStatus = statusDtoSchema.parse(sanitizeCredentialPayload(result.value, credentialValues));
1147
1416
  const run = mapRun(updateConversationId, safeStatus);
1148
1417
  const previous = this.#runs.get(conversationId);
1149
1418
  this.#runs.set(conversationId, run);