@turingfocus/chat-gateway-tfrobot 0.4.2 → 0.6.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.
package/dist/socket.js CHANGED
@@ -17,6 +17,123 @@ const KNOWN_EVENTS = new Set([
17
17
  ]);
18
18
  const MAX_TIMER_DELAY = 2_147_483_647;
19
19
  const RECONNECT_AUTH_TIMEOUT_MS = 10_000;
20
+ const RECONNECT_JOIN_TIMEOUT_MS = 10_000;
21
+ const parseJoinAcknowledgement = (value, reconnect) => {
22
+ if (value === undefined) {
23
+ return {
24
+ accepted: reconnect,
25
+ recoveryComplete: false,
26
+ ...(reconnect ? {} : { rejectionCode: "validation" }),
27
+ };
28
+ }
29
+ if (value === null) {
30
+ return {
31
+ accepted: false,
32
+ recoveryComplete: false,
33
+ rejectionCode: "validation",
34
+ };
35
+ }
36
+ if (value === false) {
37
+ return {
38
+ accepted: false,
39
+ recoveryComplete: false,
40
+ rejectionCode: "validation",
41
+ };
42
+ }
43
+ if (value === true) {
44
+ return { accepted: true, recoveryComplete: !reconnect };
45
+ }
46
+ if (typeof value !== "object") {
47
+ return {
48
+ accepted: false,
49
+ recoveryComplete: false,
50
+ rejectionCode: "validation",
51
+ };
52
+ }
53
+ const record = value;
54
+ const booleanAliases = ["ok", "accepted", "success"];
55
+ const hasInvalidBooleanAlias = booleanAliases.some((alias) => Object.hasOwn(record, alias) && typeof record[alias] !== "boolean");
56
+ const statusAliases = ["status", "statusCode", "code"];
57
+ const hasInvalidStatusAlias = statusAliases.some((alias) => Object.hasOwn(record, alias) && record[alias] === undefined);
58
+ const statuses = [record["status"], record["statusCode"], record["code"]]
59
+ .filter((status) => status !== undefined)
60
+ .map((status) => {
61
+ const numeric = typeof status === "number"
62
+ ? status
63
+ : typeof status === "string" && /^\d{3}$/u.test(status)
64
+ ? Number(status)
65
+ : undefined;
66
+ const normalized = typeof status === "string" ? status.trim().toLowerCase() : undefined;
67
+ const accepted = (numeric !== undefined && numeric >= 200 && numeric < 300) ||
68
+ normalized === "ok" ||
69
+ normalized === "success" ||
70
+ normalized === "accepted" ||
71
+ normalized === "joined";
72
+ const rejected = (numeric !== undefined && numeric >= 400) ||
73
+ normalized === "error" ||
74
+ normalized === "failed" ||
75
+ normalized === "forbidden" ||
76
+ normalized === "unauthorized" ||
77
+ normalized === "rejected";
78
+ return { accepted, normalized, numeric, rejected };
79
+ });
80
+ const hasUnknownStatus = statuses.some((status) => !status.accepted && !status.rejected);
81
+ const explicitlyAccepted = record["ok"] === true ||
82
+ record["accepted"] === true ||
83
+ record["success"] === true ||
84
+ statuses.some((status) => status.accepted);
85
+ const cursorAliases = ["cursor", "recoveryCursor"];
86
+ const cursorValues = cursorAliases
87
+ .filter((alias) => Object.hasOwn(record, alias))
88
+ .map((alias) => record[alias]);
89
+ const cursorInvalid = cursorValues.some((cursorValue) => typeof cursorValue !== "string");
90
+ const cursorConflict = cursorValues.length > 1 &&
91
+ cursorValues.some((cursorValue) => cursorValue !== cursorValues[0]);
92
+ const recoveryAliases = ["recovered", "recoveryComplete"];
93
+ const recoveryDeclarations = recoveryAliases
94
+ .filter((alias) => Object.hasOwn(record, alias))
95
+ .map((alias) => record[alias]);
96
+ const recoveryInvalid = recoveryDeclarations.some((declaration) => typeof declaration !== "boolean");
97
+ const recoveryConflict = recoveryDeclarations.includes(true) && recoveryDeclarations.includes(false);
98
+ const normalizedError = typeof record["error"] === "string"
99
+ ? record["error"].trim().toLowerCase()
100
+ : undefined;
101
+ const explicitlyRejected = hasInvalidBooleanAlias ||
102
+ hasInvalidStatusAlias ||
103
+ record["ok"] === false ||
104
+ record["accepted"] === false ||
105
+ record["success"] === false ||
106
+ record["error"] !== undefined ||
107
+ cursorInvalid ||
108
+ cursorConflict ||
109
+ recoveryInvalid ||
110
+ recoveryConflict ||
111
+ (!reconnect && recoveryDeclarations.includes(false)) ||
112
+ hasUnknownStatus ||
113
+ statuses.some((status) => status.rejected);
114
+ const accepted = explicitlyAccepted && !explicitlyRejected;
115
+ const cursor = cursorValues[0];
116
+ const recoveryComplete = !reconnect ||
117
+ (recoveryDeclarations.length > 0 &&
118
+ recoveryDeclarations.every((declaration) => declaration === true));
119
+ const authenticationRejected = statuses.some(({ normalized, numeric }) => numeric === 401 || normalized === "unauthorized") || normalizedError === "unauthorized";
120
+ const authorizationRejected = statuses.some(({ normalized, numeric }) => numeric === 403 || normalized === "forbidden") || normalizedError === "forbidden";
121
+ return {
122
+ accepted,
123
+ ...(authenticationRejected
124
+ ? { rejectionCode: "authentication" }
125
+ : authorizationRejected
126
+ ? { rejectionCode: "authorization" }
127
+ : accepted
128
+ ? {}
129
+ : { rejectionCode: "validation" }),
130
+ recoveryComplete,
131
+ ...(typeof cursor === "string" ? { cursor } : {}),
132
+ ...(typeof record["message"] === "string"
133
+ ? { message: record["message"] }
134
+ : {}),
135
+ };
136
+ };
20
137
  const sameRun = (first, second) => first === second ||
21
138
  (first != null &&
22
139
  second != null &&
@@ -102,9 +219,9 @@ export const createSocketIoFactoryWith = (connect) => ({ getAuth, namespaceUrl,
102
219
  });
103
220
  export const createSocketIoFactory = createSocketIoFactoryWith(io);
104
221
  export class TFRobotSocketClient {
105
- #active;
106
222
  #disposed = false;
107
223
  #establishmentAbort;
224
+ #establishing;
108
225
  #factory;
109
226
  #namespaceUrl;
110
227
  #now;
@@ -112,6 +229,8 @@ export class TFRobotSocketClient {
112
229
  #path;
113
230
  #reconnectStatusLoader;
114
231
  #runs = new Map();
232
+ #subscriptions = new Set();
233
+ #subscriptionGeneration = 0;
115
234
  constructor(options, reconnectStatusLoader) {
116
235
  this.#options = options;
117
236
  this.#factory = options.socketFactory ?? createSocketIoFactory;
@@ -134,7 +253,9 @@ export class TFRobotSocketClient {
134
253
  error: createGatewayDeadlineExceededError(),
135
254
  };
136
255
  }
137
- this.#deactivateCurrent();
256
+ const generation = ++this.#subscriptionGeneration;
257
+ const subscriptionId = `tfrobot-subscription-${generation}`;
258
+ this.#cancelEstablishment();
138
259
  const establishmentAbort = new AbortController();
139
260
  this.#establishmentAbort = establishmentAbort;
140
261
  const authOutcome = await awaitBounded(() => this.#getSocketAuth(conversationId, "connect"), {
@@ -142,9 +263,6 @@ export class TFRobotSocketClient {
142
263
  now: this.#now,
143
264
  signal: establishmentAbort.signal,
144
265
  });
145
- if (this.#establishmentAbort === establishmentAbort) {
146
- this.#establishmentAbort = undefined;
147
- }
148
266
  switch (authOutcome.kind) {
149
267
  case "aborted": {
150
268
  return {
@@ -224,6 +342,20 @@ export class TFRobotSocketClient {
224
342
  };
225
343
  }
226
344
  const listeners = new Map();
345
+ let joinTimeout;
346
+ const activeErrorIds = new Map();
347
+ const replaceableErrorSources = new Set([
348
+ "authentication",
349
+ "connection",
350
+ "recovery",
351
+ "subscription",
352
+ ]);
353
+ let errorSequence = 0;
354
+ let publishUpdate = () => undefined;
355
+ const pendingNotifications = [];
356
+ const pendingRealtimeActions = [];
357
+ let joinPending = false;
358
+ let subscriptionEstablished = false;
227
359
  let setupFailure;
228
360
  const add = (eventName, listener) => {
229
361
  if (setupFailure !== undefined)
@@ -244,15 +376,52 @@ export class TFRobotSocketClient {
244
376
  // Diagnostics are observational and cannot break the chat stream.
245
377
  }
246
378
  };
247
- const report = (error) => {
248
- if (!active.active)
249
- return;
379
+ const notifyError = (error) => {
250
380
  try {
251
381
  observer.error?.(error);
252
382
  }
253
383
  catch {
254
384
  // Host observers are isolated from the transport listener.
255
385
  }
386
+ };
387
+ const resolveError = (source) => {
388
+ const errorId = activeErrorIds.get(source);
389
+ if (errorId === undefined || !active.active)
390
+ return;
391
+ activeErrorIds.delete(source);
392
+ publishUpdate(chatUpdateSchema.parse({
393
+ kind: "error.resolved",
394
+ conversationId: errorConversationId(),
395
+ errorId,
396
+ }));
397
+ };
398
+ const report = (error, source = "protocol") => {
399
+ if (!active.active)
400
+ return;
401
+ if (replaceableErrorSources.has(source))
402
+ resolveError(source);
403
+ const errorId = `${subscriptionId}:error:${++errorSequence}`;
404
+ if (replaceableErrorSources.has(source)) {
405
+ activeErrorIds.set(source, errorId);
406
+ }
407
+ const occurrenceError = error.conversationId === undefined
408
+ ? error
409
+ : { ...error, conversationId: errorConversationId() };
410
+ publishUpdate(chatUpdateSchema.parse({
411
+ kind: "error.reported",
412
+ conversationId: errorConversationId(),
413
+ error: occurrenceError,
414
+ errorId,
415
+ source,
416
+ scope: source === "domain"
417
+ ? { kind: "conversation", id: errorConversationId() }
418
+ : { kind: "subscription", id: subscriptionId },
419
+ generation,
420
+ }));
421
+ if (subscriptionEstablished)
422
+ notifyError(error);
423
+ else
424
+ pendingNotifications.push({ kind: "error", error });
256
425
  diagnose(error);
257
426
  };
258
427
  const sanitizePayload = (payload) => {
@@ -276,6 +445,10 @@ export class TFRobotSocketClient {
276
445
  updateConversationId !== errorConversationId()) {
277
446
  return;
278
447
  }
448
+ if (!subscriptionEstablished) {
449
+ pendingNotifications.push({ kind: "update", update });
450
+ return;
451
+ }
279
452
  try {
280
453
  observer.next(update);
281
454
  }
@@ -283,6 +456,74 @@ export class TFRobotSocketClient {
283
456
  diagnose(this.#error("unknown", "TFRobot Gateway observer rejected an update", false, errorConversationId()));
284
457
  }
285
458
  };
459
+ publishUpdate = next;
460
+ const dispatchRealtime = (action) => {
461
+ if (active.acceptingEvents)
462
+ action();
463
+ else if (joinPending)
464
+ pendingRealtimeActions.push(action);
465
+ };
466
+ const flushRealtime = () => {
467
+ for (const action of pendingRealtimeActions.splice(0))
468
+ action();
469
+ };
470
+ const lifecycle = (status, recovery, joinLatencyMs) => {
471
+ if (!active.active)
472
+ return;
473
+ const value = {
474
+ status,
475
+ generation,
476
+ reconnectAttempt: active.reconnectAttempt,
477
+ subscriptionId,
478
+ ...(recovery === undefined ? {} : { recovery }),
479
+ };
480
+ const update = chatUpdateSchema.parse({
481
+ kind: "lifecycle.changed",
482
+ conversationId: errorConversationId(),
483
+ lifecycle: value,
484
+ });
485
+ next(update);
486
+ try {
487
+ void Promise.resolve(this.#options.onLifecycleDiagnostic?.({
488
+ kind: "socket.lifecycle",
489
+ conversationId: errorConversationId(),
490
+ subscriptionId,
491
+ generation,
492
+ status,
493
+ reconnectAttempt: active.reconnectAttempt,
494
+ ...(joinLatencyMs === undefined ? {} : { joinLatencyMs }),
495
+ ...(recovery === undefined
496
+ ? {}
497
+ : {
498
+ recoveryComplete: recovery.complete,
499
+ ...(recovery.cursor === undefined
500
+ ? {}
501
+ : { recoveryCursor: recovery.cursor }),
502
+ ...(recovery.reason === undefined
503
+ ? {}
504
+ : { recoveryReason: recovery.reason }),
505
+ }),
506
+ })).catch(() => undefined);
507
+ }
508
+ catch {
509
+ // Lifecycle diagnostics are observational.
510
+ }
511
+ };
512
+ const enterAuthRequired = () => {
513
+ active.acceptingEvents = false;
514
+ active.authRequired = true;
515
+ active.terminal = true;
516
+ joinPending = false;
517
+ pendingRealtimeActions.length = 0;
518
+ active.recoveryRevision += 1;
519
+ lifecycle("auth-required");
520
+ try {
521
+ active.socket.disconnect();
522
+ }
523
+ catch {
524
+ // Authentication is already terminal for this subscription episode.
525
+ }
526
+ };
286
527
  const mapAndNext = (invalidMessage, map) => {
287
528
  let update;
288
529
  try {
@@ -295,10 +536,18 @@ export class TFRobotSocketClient {
295
536
  next(update);
296
537
  };
297
538
  let settleEstablishment;
298
- let connectedOnce = false;
299
539
  add("connect", () => {
300
540
  if (!active.active)
301
541
  return;
542
+ if (active.terminal) {
543
+ try {
544
+ active.socket.disconnect();
545
+ }
546
+ catch {
547
+ // Terminal subscriptions never rejoin.
548
+ }
549
+ return;
550
+ }
302
551
  if (settleEstablishment !== undefined &&
303
552
  isGatewayDeadlineExceeded(options, this.#now())) {
304
553
  settleEstablishment({
@@ -307,13 +556,206 @@ export class TFRobotSocketClient {
307
556
  });
308
557
  return;
309
558
  }
310
- const reconnect = connectedOnce;
311
- connectedOnce = true;
559
+ if (joinTimeout !== undefined) {
560
+ clearTimeout(joinTimeout);
561
+ joinTimeout = undefined;
562
+ }
563
+ const reconnect = subscriptionEstablished;
564
+ if (reconnect)
565
+ active.reconnectAttempt += 1;
312
566
  const recoveryRevision = ++active.recoveryRevision;
313
- try {
314
- socket.emit("join_conversation", {
315
- conversation_id: conversationId,
567
+ const runRevision = active.runRevision;
568
+ const joinStartedAt = this.#now();
569
+ active.acceptingEvents = false;
570
+ joinPending = true;
571
+ pendingRealtimeActions.length = 0;
572
+ lifecycle("joining");
573
+ let acknowledgementSettled = false;
574
+ const settleJoin = (...acknowledgementArguments) => {
575
+ if (!active.active ||
576
+ !this.#subscriptions.has(active) ||
577
+ active.recoveryRevision !== recoveryRevision) {
578
+ return;
579
+ }
580
+ if (acknowledgementSettled)
581
+ return;
582
+ acknowledgementSettled = true;
583
+ if (joinTimeout !== undefined) {
584
+ clearTimeout(joinTimeout);
585
+ joinTimeout = undefined;
586
+ }
587
+ let acknowledgement;
588
+ try {
589
+ const rawAcknowledgement = acknowledgementArguments[0];
590
+ acknowledgement = parseJoinAcknowledgement(rawAcknowledgement === undefined || rawAcknowledgement === true
591
+ ? rawAcknowledgement
592
+ : sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect);
593
+ }
594
+ catch {
595
+ acknowledgement = {
596
+ accepted: false,
597
+ recoveryComplete: false,
598
+ rejectionCode: "validation",
599
+ message: "Invalid TFRobot conversation subscription acknowledgement",
600
+ };
601
+ }
602
+ if (!acknowledgement.accepted) {
603
+ joinPending = false;
604
+ pendingRealtimeActions.length = 0;
605
+ const rejectionCode = acknowledgement.rejectionCode ?? "validation";
606
+ const error = this.#error(rejectionCode, acknowledgement.message ??
607
+ "TFRobot conversation subscription was rejected", false, errorConversationId());
608
+ if (settleEstablishment !== undefined) {
609
+ lifecycle("subscription-failed");
610
+ if (rejectionCode === "authentication" ||
611
+ rejectionCode === "authorization") {
612
+ void this.#invalidateSession(error, "rejected");
613
+ }
614
+ settleEstablishment({ ok: false, error });
615
+ }
616
+ else {
617
+ const authenticationRejected = rejectionCode === "authentication" ||
618
+ rejectionCode === "authorization";
619
+ if (authenticationRejected) {
620
+ enterAuthRequired();
621
+ }
622
+ else {
623
+ active.acceptingEvents = false;
624
+ active.terminal = true;
625
+ active.recoveryRevision += 1;
626
+ try {
627
+ active.socket.disconnect();
628
+ }
629
+ catch {
630
+ // Subscription failure is already terminal for this episode.
631
+ }
632
+ lifecycle("subscription-failed");
633
+ }
634
+ report(error, authenticationRejected ? "authentication" : "subscription");
635
+ if (authenticationRejected) {
636
+ void this.#invalidateSession(error, "rejected");
637
+ }
638
+ }
639
+ return;
640
+ }
641
+ if (!reconnect) {
642
+ joinPending = false;
643
+ subscriptionEstablished = true;
644
+ for (const notification of pendingNotifications.splice(0)) {
645
+ if (notification.kind === "update")
646
+ next(notification.update);
647
+ else
648
+ notifyError(notification.error);
649
+ }
650
+ settleEstablishment?.({
651
+ ok: true,
652
+ value: {
653
+ dispose: () => {
654
+ if (!active.active)
655
+ return;
656
+ active.active = false;
657
+ active.cleanup();
658
+ this.#subscriptions.delete(active);
659
+ },
660
+ },
661
+ });
662
+ resolveError("connection");
663
+ resolveError("authentication");
664
+ lifecycle("active", {
665
+ complete: true,
666
+ ...(acknowledgement.cursor === undefined
667
+ ? {}
668
+ : { cursor: acknowledgement.cursor }),
669
+ }, this.#now() - joinStartedAt);
670
+ active.acceptingEvents = true;
671
+ flushRealtime();
672
+ return;
673
+ }
674
+ joinPending = false;
675
+ const joinLatencyMs = this.#now() - joinStartedAt;
676
+ lifecycle("recovering", {
677
+ complete: false,
678
+ ...(acknowledgement.cursor === undefined
679
+ ? {}
680
+ : { cursor: acknowledgement.cursor }),
681
+ ...(acknowledgement.recoveryComplete
682
+ ? {}
683
+ : { reason: "server-replay-contract-unavailable" }),
684
+ }, joinLatencyMs);
685
+ active.acceptingEvents = true;
686
+ flushRealtime();
687
+ void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report)
688
+ .then((outcome) => {
689
+ if (outcome === "auth-required") {
690
+ enterAuthRequired();
691
+ return;
692
+ }
693
+ if ((outcome !== "success" && outcome !== "superseded-by-realtime") ||
694
+ !acknowledgement.recoveryComplete) {
695
+ return;
696
+ }
697
+ resolveError("connection");
698
+ resolveError("recovery");
699
+ resolveError("authentication");
700
+ active.manualReconnectAttempted = false;
701
+ active.manualReconnectPending = false;
702
+ active.authRequired = false;
703
+ active.terminal = false;
704
+ lifecycle("active", {
705
+ complete: true,
706
+ ...(acknowledgement.cursor === undefined
707
+ ? {}
708
+ : { cursor: acknowledgement.cursor }),
709
+ }, joinLatencyMs);
710
+ })
711
+ .catch(() => {
712
+ active.acceptingEvents = false;
713
+ active.terminal = true;
714
+ try {
715
+ active.socket.disconnect();
716
+ }
717
+ catch {
718
+ // Offline is already terminal for this subscription episode.
719
+ }
720
+ lifecycle("offline");
721
+ report(this.#error("unknown", "TFRobot reconnect reconciliation failed", true, errorConversationId()), "recovery");
316
722
  });
723
+ };
724
+ const joinDeadlineAt = reconnect
725
+ ? this.#now() + RECONNECT_JOIN_TIMEOUT_MS
726
+ : options.deadlineAt;
727
+ joinTimeout = setTimeout(() => {
728
+ joinTimeout = undefined;
729
+ if (!active.active || active.recoveryRevision !== recoveryRevision) {
730
+ return;
731
+ }
732
+ active.recoveryRevision += 1;
733
+ joinPending = false;
734
+ pendingRealtimeActions.length = 0;
735
+ const error = createGatewayDeadlineExceededError(errorConversationId());
736
+ if (settleEstablishment !== undefined) {
737
+ lifecycle("subscription-failed");
738
+ settleEstablishment({ ok: false, error });
739
+ }
740
+ else {
741
+ if (active.manualReconnectPending)
742
+ enterAuthRequired();
743
+ else {
744
+ active.acceptingEvents = false;
745
+ active.terminal = true;
746
+ lifecycle("subscription-failed");
747
+ }
748
+ report(error, active.manualReconnectPending ? "connection" : "subscription");
749
+ try {
750
+ active.socket.disconnect();
751
+ }
752
+ catch {
753
+ // The failed join attempt is already invalidated.
754
+ }
755
+ }
756
+ }, Math.min(MAX_TIMER_DELAY, Math.max(0, joinDeadlineAt - this.#now())));
757
+ try {
758
+ socket.emit("join_conversation", { conversation_id: conversationId }, settleJoin);
317
759
  }
318
760
  catch (reason) {
319
761
  const error = this.#transportError(reason, "Unable to join the TFRobot conversation", conversationId, credentialValues);
@@ -321,112 +763,97 @@ export class TFRobotSocketClient {
321
763
  settleEstablishment({ ok: false, error });
322
764
  }
323
765
  else {
324
- report(error);
766
+ report(error, "subscription");
325
767
  active.active = false;
326
768
  active.cleanup();
327
- if (this.#active === active)
328
- this.#active = undefined;
769
+ this.#subscriptions.delete(active);
329
770
  }
330
771
  return;
331
772
  }
332
- if (settleEstablishment !== undefined &&
333
- isGatewayDeadlineExceeded(options, this.#now())) {
334
- settleEstablishment({
335
- ok: false,
336
- error: createGatewayDeadlineExceededError(errorConversationId()),
337
- });
338
- return;
339
- }
340
- settleEstablishment?.({
341
- ok: true,
342
- value: {
343
- dispose: () => {
344
- if (!active.active)
345
- return;
346
- active.active = false;
347
- active.cleanup();
348
- if (this.#active === active)
349
- this.#active = undefined;
350
- },
351
- },
352
- });
353
- if (reconnect) {
354
- void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, next, report).catch(() => {
355
- report(this.#error("unknown", "TFRobot reconnect reconciliation failed", true, errorConversationId()));
356
- });
357
- }
358
773
  });
359
774
  add("chat_message", (payload) => {
360
- if (belongsToForeignConversation(payload, conversationId))
361
- return;
362
- const parsed = messageDtoSchema.safeParse(sanitizePayload(payload));
363
- if (!parsed.success) {
364
- report(this.#error("validation", "Invalid TFRobot chat_message payload", false, errorConversationId()));
365
- return;
366
- }
367
- mapAndNext("TFRobot chat_message could not be normalized", () => mapMessageUpdate(parsed.data));
775
+ dispatchRealtime(() => {
776
+ if (belongsToForeignConversation(payload, conversationId))
777
+ return;
778
+ const parsed = messageDtoSchema.safeParse(sanitizePayload(payload));
779
+ if (!parsed.success) {
780
+ report(this.#error("validation", "Invalid TFRobot chat_message payload", false, errorConversationId()));
781
+ return;
782
+ }
783
+ mapAndNext("TFRobot chat_message could not be normalized", () => mapMessageUpdate(parsed.data));
784
+ });
368
785
  });
369
786
  add("chat_event", (payload) => {
370
- if (belongsToForeignConversation(payload, conversationId))
371
- return;
372
- const parsed = eventDtoSchema.safeParse(sanitizePayload(payload));
373
- if (!parsed.success) {
374
- report(this.#error("validation", "Invalid TFRobot chat_event payload", false, errorConversationId()));
375
- return;
376
- }
377
- mapAndNext("TFRobot chat_event could not be normalized", () => mapEventUpdate(parsed.data));
787
+ dispatchRealtime(() => {
788
+ if (belongsToForeignConversation(payload, conversationId))
789
+ return;
790
+ const parsed = eventDtoSchema.safeParse(sanitizePayload(payload));
791
+ if (!parsed.success) {
792
+ report(this.#error("validation", "Invalid TFRobot chat_event payload", false, errorConversationId()));
793
+ return;
794
+ }
795
+ mapAndNext("TFRobot chat_event could not be normalized", () => mapEventUpdate(parsed.data));
796
+ });
378
797
  });
379
798
  add("conversation_state_changed", (payload) => {
380
- if (belongsToForeignConversation(payload, conversationId))
381
- return;
382
- const parsed = stateChangedDtoSchema.safeParse(sanitizePayload(payload));
383
- if (!parsed.success) {
384
- report(this.#error("validation", "Invalid TFRobot conversation_state_changed payload", false, errorConversationId()));
385
- return;
386
- }
387
- const targetConversationId = String(parsed.data.conversationId);
388
- if (targetConversationId !== conversationId &&
389
- targetConversationId !== errorConversationId()) {
390
- return;
391
- }
392
- active.recoveryRevision += 1;
393
- mapAndNext("TFRobot run state could not be normalized", () => {
394
- const run = mapRun(targetConversationId, {
395
- working: parsed.data.state === "working",
396
- taskId: parsed.data.taskId,
397
- });
398
- this.#runs.set(targetConversationId, run);
399
- return chatUpdateSchema.parse({
400
- kind: "run.replace",
401
- conversationId: targetConversationId,
402
- run,
799
+ dispatchRealtime(() => {
800
+ if (belongsToForeignConversation(payload, conversationId))
801
+ return;
802
+ const parsed = stateChangedDtoSchema.safeParse(sanitizePayload(payload));
803
+ if (!parsed.success) {
804
+ report(this.#error("validation", "Invalid TFRobot conversation_state_changed payload", false, errorConversationId()));
805
+ return;
806
+ }
807
+ const targetConversationId = String(parsed.data.conversationId);
808
+ if (targetConversationId !== conversationId &&
809
+ targetConversationId !== errorConversationId()) {
810
+ return;
811
+ }
812
+ active.runRevision += 1;
813
+ mapAndNext("TFRobot run state could not be normalized", () => {
814
+ const run = mapRun(targetConversationId, {
815
+ working: parsed.data.state === "working",
816
+ taskId: parsed.data.taskId,
817
+ });
818
+ this.#runs.set(targetConversationId, run);
819
+ return chatUpdateSchema.parse({
820
+ kind: "run.replace",
821
+ conversationId: targetConversationId,
822
+ run,
823
+ });
403
824
  });
404
825
  });
405
826
  });
406
827
  add("chat_error", (payload) => {
407
- if (belongsToForeignConversation(payload, conversationId))
408
- return;
409
- const parsed = chatErrorEventDtoSchema.safeParse(sanitizePayload(payload));
410
- if (!parsed.success) {
411
- report(this.#error("validation", "Invalid TFRobot chat_error payload", false, errorConversationId()));
412
- return;
413
- }
414
- const targetConversationId = String(parsed.data.conversationId);
415
- if (targetConversationId !== conversationId &&
416
- targetConversationId !== errorConversationId()) {
417
- return;
418
- }
419
- report(this.#error("server", typeof parsed.data.error === "string"
420
- ? parsed.data.error
421
- : "TFRobot run failed", false, targetConversationId, parsed.data, credentialValues));
828
+ dispatchRealtime(() => {
829
+ if (belongsToForeignConversation(payload, conversationId))
830
+ return;
831
+ const parsed = chatErrorEventDtoSchema.safeParse(sanitizePayload(payload));
832
+ if (!parsed.success) {
833
+ report(this.#error("validation", "Invalid TFRobot chat_error payload", false, errorConversationId()));
834
+ return;
835
+ }
836
+ const targetConversationId = String(parsed.data.conversationId);
837
+ if (targetConversationId !== conversationId &&
838
+ targetConversationId !== errorConversationId()) {
839
+ return;
840
+ }
841
+ report(this.#error("server", typeof parsed.data.error === "string"
842
+ ? parsed.data.error
843
+ : "TFRobot run failed", false, targetConversationId, parsed.data, credentialValues), "domain");
844
+ });
422
845
  });
423
846
  add("error", (payload) => {
424
- const parsed = socketProtocolErrorDtoSchema.safeParse(sanitizePayload(payload));
425
- report(this.#error("validation", parsed.success && typeof parsed.data.message === "string"
426
- ? parsed.data.message
427
- : "TFRobot Socket protocol error", false, conversationId, undefined, credentialValues));
847
+ dispatchRealtime(() => {
848
+ const parsed = socketProtocolErrorDtoSchema.safeParse(sanitizePayload(payload));
849
+ report(this.#error("validation", parsed.success && typeof parsed.data.message === "string"
850
+ ? parsed.data.message
851
+ : "TFRobot Socket protocol error", false, conversationId, undefined, credentialValues));
852
+ });
428
853
  });
429
854
  add("connect_error", (reason) => {
855
+ if (!active.active || active.terminal)
856
+ return;
430
857
  const injectedError = chatErrorSchema.safeParse(reason);
431
858
  const rejectionCode = socketAuthRejectionCode(reason);
432
859
  const error = authenticationFailure !== undefined
@@ -448,23 +875,82 @@ export class TFRobotSocketClient {
448
875
  settleEstablishment({ ok: false, error });
449
876
  return;
450
877
  }
451
- report(error);
878
+ if (active.manualReconnectPending) {
879
+ enterAuthRequired();
880
+ report(error, error.code === "authentication" || error.code === "authorization"
881
+ ? "authentication"
882
+ : "connection");
883
+ return;
884
+ }
885
+ if (error.code === "authentication" || error.code === "authorization") {
886
+ enterAuthRequired();
887
+ }
888
+ report(error, error.code === "authentication" || error.code === "authorization"
889
+ ? "authentication"
890
+ : "connection");
452
891
  });
453
892
  add("disconnect", (reason) => {
454
893
  if (reason === "io client disconnect")
455
894
  return;
895
+ if (active.terminal)
896
+ return;
897
+ active.acceptingEvents = false;
898
+ joinPending = false;
899
+ pendingRealtimeActions.length = 0;
900
+ if (joinTimeout !== undefined) {
901
+ clearTimeout(joinTimeout);
902
+ joinTimeout = undefined;
903
+ }
456
904
  active.recoveryRevision += 1;
905
+ lifecycle("reconnecting");
457
906
  const injectedError = chatErrorSchema.safeParse(reason);
458
907
  report(injectedError.success
459
908
  ? sanitizeCredentialError(injectedError.data, credentialValues)
460
- : this.#error("network", `TFRobot Socket disconnected: ${String(reason)}`, true, conversationId, undefined, credentialValues));
461
- });
462
- const anyListener = (eventName, payload) => {
463
- if (KNOWN_EVENTS.has(eventName))
909
+ : this.#error("network", `TFRobot Socket disconnected: ${String(reason)}`, true, conversationId, undefined, credentialValues), "connection");
910
+ if (reason !== "io server disconnect")
464
911
  return;
465
- if (belongsToForeignConversation(payload, conversationId))
912
+ if (active.manualReconnectAttempted) {
913
+ enterAuthRequired();
466
914
  return;
467
- mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), sanitizePayload(payload), errorConversationId(), this.#now()));
915
+ }
916
+ active.manualReconnectAttempted = true;
917
+ active.manualReconnectPending = true;
918
+ const invalidationRevision = active.recoveryRevision;
919
+ const invalidationError = this.#error("authentication", "TFRobot Socket session was invalidated by the server", true, errorConversationId());
920
+ void awaitBounded(() => this.#invalidateSession(invalidationError, "unknown"), {
921
+ deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
922
+ now: this.#now,
923
+ signal: connectionAbort.signal,
924
+ }).then((outcome) => {
925
+ if (!active.active ||
926
+ active.socket.connected ||
927
+ active.authRequired ||
928
+ !active.manualReconnectPending ||
929
+ active.recoveryRevision !== invalidationRevision) {
930
+ return;
931
+ }
932
+ if (outcome.kind !== "value" || !outcome.value) {
933
+ report(this.#error("authentication", "Unable to invalidate the TFRobot Socket session", false, errorConversationId()), "authentication");
934
+ enterAuthRequired();
935
+ return;
936
+ }
937
+ try {
938
+ active.socket.connect();
939
+ }
940
+ catch (reconnectError) {
941
+ report(this.#transportError(reconnectError, "Unable to reconnect the TFRobot Socket transport", conversationId, credentialValues), "authentication");
942
+ enterAuthRequired();
943
+ }
944
+ });
945
+ });
946
+ const anyListener = (eventName, payload) => {
947
+ dispatchRealtime(() => {
948
+ if (KNOWN_EVENTS.has(eventName))
949
+ return;
950
+ if (belongsToForeignConversation(payload, conversationId))
951
+ return;
952
+ mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), sanitizePayload(payload), errorConversationId(), this.#now()));
953
+ });
468
954
  };
469
955
  if (setupFailure === undefined) {
470
956
  try {
@@ -476,6 +962,10 @@ export class TFRobotSocketClient {
476
962
  }
477
963
  const cleanup = () => {
478
964
  connectionAbort.abort();
965
+ if (joinTimeout !== undefined) {
966
+ clearTimeout(joinTimeout);
967
+ joinTimeout = undefined;
968
+ }
479
969
  for (const [eventName, listener] of listeners) {
480
970
  try {
481
971
  socket.off(eventName, listener);
@@ -498,6 +988,8 @@ export class TFRobotSocketClient {
498
988
  }
499
989
  firstAuth = undefined;
500
990
  credentialValues.clear();
991
+ pendingNotifications.length = 0;
992
+ pendingRealtimeActions.length = 0;
501
993
  };
502
994
  if (setupFailure !== undefined) {
503
995
  const error = this.#transportError(setupFailure.reason, "Unable to configure the TFRobot Socket transport", conversationId, credentialValues);
@@ -508,7 +1000,9 @@ export class TFRobotSocketClient {
508
1000
  };
509
1001
  }
510
1002
  const active = {
1003
+ acceptingEvents: true,
511
1004
  active: true,
1005
+ authRequired: false,
512
1006
  cancelEstablishment: () => {
513
1007
  settleEstablishment?.({
514
1008
  ok: false,
@@ -517,11 +1011,19 @@ export class TFRobotSocketClient {
517
1011
  },
518
1012
  cleanup,
519
1013
  conversationId,
1014
+ generation,
1015
+ manualReconnectAttempted: false,
1016
+ manualReconnectPending: false,
520
1017
  observer,
1018
+ reconnectAttempt: 0,
521
1019
  recoveryRevision: 0,
1020
+ runRevision: 0,
522
1021
  socket,
1022
+ subscriptionId,
1023
+ terminal: false,
523
1024
  };
524
- this.#active = active;
1025
+ this.#subscriptions.add(active);
1026
+ this.#establishing = active;
525
1027
  return new Promise((resolve) => {
526
1028
  let settled = false;
527
1029
  const settle = (result) => {
@@ -529,12 +1031,16 @@ export class TFRobotSocketClient {
529
1031
  return;
530
1032
  settled = true;
531
1033
  settleEstablishment = undefined;
1034
+ if (this.#establishmentAbort === establishmentAbort) {
1035
+ this.#establishmentAbort = undefined;
1036
+ }
1037
+ if (this.#establishing === active)
1038
+ this.#establishing = undefined;
532
1039
  clearTimeout(timeout);
533
1040
  if (!result.ok) {
534
1041
  active.active = false;
535
1042
  active.cleanup();
536
- if (this.#active === active)
537
- this.#active = undefined;
1043
+ this.#subscriptions.delete(active);
538
1044
  }
539
1045
  resolve(result);
540
1046
  };
@@ -553,6 +1059,7 @@ export class TFRobotSocketClient {
553
1059
  return;
554
1060
  }
555
1061
  try {
1062
+ lifecycle("connecting");
556
1063
  socket.connect();
557
1064
  }
558
1065
  catch (reason) {
@@ -574,7 +1081,12 @@ export class TFRobotSocketClient {
574
1081
  if (this.#disposed)
575
1082
  return;
576
1083
  this.#disposed = true;
577
- this.#deactivateCurrent();
1084
+ this.#cancelEstablishment();
1085
+ for (const subscription of this.#subscriptions) {
1086
+ subscription.active = false;
1087
+ subscription.cleanup();
1088
+ }
1089
+ this.#subscriptions.clear();
578
1090
  this.#runs.clear();
579
1091
  }
580
1092
  rememberRun(conversationId, run) {
@@ -582,16 +1094,11 @@ export class TFRobotSocketClient {
582
1094
  return;
583
1095
  this.#runs.set(conversationId, run);
584
1096
  }
585
- #deactivateCurrent() {
1097
+ #cancelEstablishment() {
586
1098
  this.#establishmentAbort?.abort();
587
1099
  this.#establishmentAbort = undefined;
588
- const current = this.#active;
589
- if (current === undefined)
590
- return;
591
- current.cancelEstablishment();
592
- current.active = false;
593
- current.cleanup();
594
- this.#active = undefined;
1100
+ this.#establishing?.cancelEstablishment();
1101
+ this.#establishing = undefined;
595
1102
  }
596
1103
  #defaultNamespaceUrl(baseUrl) {
597
1104
  const url = new URL(baseUrl);
@@ -611,17 +1118,28 @@ export class TFRobotSocketClient {
611
1118
  }
612
1119
  return authOf(session);
613
1120
  }
614
- async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, next, report) {
1121
+ async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report) {
615
1122
  const result = await this.#reconnectStatusLoader(conversationId);
616
1123
  if (!active.active ||
617
- this.#active !== active ||
1124
+ !this.#subscriptions.has(active) ||
618
1125
  !active.socket.connected ||
619
1126
  active.recoveryRevision !== recoveryRevision) {
620
- return;
1127
+ return "failure";
621
1128
  }
622
1129
  if (!result.ok) {
623
- report(sanitizeCredentialError(result.error, credentialValues));
624
- return;
1130
+ const error = sanitizeCredentialError(result.error, credentialValues);
1131
+ if (error.code === "authentication" || error.code === "authorization") {
1132
+ report(error, "authentication");
1133
+ return "auth-required";
1134
+ }
1135
+ if (active.runRevision !== runRevision) {
1136
+ return "superseded-by-realtime";
1137
+ }
1138
+ report(error, "recovery");
1139
+ return "failure";
1140
+ }
1141
+ if (active.runRevision !== runRevision) {
1142
+ return "superseded-by-realtime";
625
1143
  }
626
1144
  try {
627
1145
  const updateConversationId = sanitizeCredentialText(conversationId, credentialValues);
@@ -629,16 +1147,18 @@ export class TFRobotSocketClient {
629
1147
  const run = mapRun(updateConversationId, safeStatus);
630
1148
  const previous = this.#runs.get(conversationId);
631
1149
  this.#runs.set(conversationId, run);
632
- if (sameRun(previous, run))
633
- return;
634
- next(chatUpdateSchema.parse({
635
- kind: "run.replace",
636
- conversationId: updateConversationId,
637
- run,
638
- }));
1150
+ if (!sameRun(previous, run)) {
1151
+ next(chatUpdateSchema.parse({
1152
+ kind: "run.replace",
1153
+ conversationId: updateConversationId,
1154
+ run,
1155
+ }));
1156
+ }
1157
+ return "success";
639
1158
  }
640
1159
  catch {
641
- report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId, undefined, credentialValues));
1160
+ report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId, undefined, credentialValues), "recovery");
1161
+ return "failure";
642
1162
  }
643
1163
  }
644
1164
  async #invalidateSession(error, reason) {
@@ -647,9 +1167,10 @@ export class TFRobotSocketClient {
647
1167
  reason: reason ?? (error.code === "authorization" ? "forbidden" : "expired"),
648
1168
  error,
649
1169
  });
1170
+ return true;
650
1171
  }
651
1172
  catch {
652
- // Host refresh/login failures must not replace the Socket error.
1173
+ return false;
653
1174
  }
654
1175
  }
655
1176
  #transportError(reason, fallback, conversationId, credentialValues = []) {