@turingfocus/chat-gateway-tfrobot 0.5.0 → 0.7.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
@@ -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
6
  import { sanitizeCredentialError, sanitizeCredentialRaw, sanitizeCredentialText, } from "./redaction.js";
7
- import { isValidTFRobotSession } from "./types.js";
7
+ import { isValidTFRobotSession, resolveTFRobotServerProfile } from "./types.js";
8
8
  const KNOWN_EVENTS = new Set([
9
9
  "chat_error",
10
10
  "chat_event",
@@ -17,6 +17,130 @@ 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, acceptEmpty) => {
22
+ if (value === undefined) {
23
+ return {
24
+ accepted: reconnect || acceptEmpty,
25
+ empty: true,
26
+ recoveryComplete: false,
27
+ ...(reconnect || acceptEmpty
28
+ ? {}
29
+ : { rejectionCode: "validation" }),
30
+ };
31
+ }
32
+ if (value === null) {
33
+ return {
34
+ accepted: false,
35
+ empty: false,
36
+ recoveryComplete: false,
37
+ rejectionCode: "validation",
38
+ };
39
+ }
40
+ if (value === false) {
41
+ return {
42
+ accepted: false,
43
+ empty: false,
44
+ recoveryComplete: false,
45
+ rejectionCode: "validation",
46
+ };
47
+ }
48
+ if (value === true) {
49
+ return { accepted: true, empty: false, recoveryComplete: !reconnect };
50
+ }
51
+ if (typeof value !== "object") {
52
+ return {
53
+ accepted: false,
54
+ empty: false,
55
+ recoveryComplete: false,
56
+ rejectionCode: "validation",
57
+ };
58
+ }
59
+ const record = value;
60
+ const booleanAliases = ["ok", "accepted", "success"];
61
+ const hasInvalidBooleanAlias = booleanAliases.some((alias) => Object.hasOwn(record, alias) && typeof record[alias] !== "boolean");
62
+ const statusAliases = ["status", "statusCode", "code"];
63
+ const hasInvalidStatusAlias = statusAliases.some((alias) => Object.hasOwn(record, alias) && record[alias] === undefined);
64
+ const statuses = [record["status"], record["statusCode"], record["code"]]
65
+ .filter((status) => status !== undefined)
66
+ .map((status) => {
67
+ const numeric = typeof status === "number"
68
+ ? status
69
+ : typeof status === "string" && /^\d{3}$/u.test(status)
70
+ ? Number(status)
71
+ : undefined;
72
+ const normalized = typeof status === "string" ? status.trim().toLowerCase() : undefined;
73
+ const accepted = (numeric !== undefined && numeric >= 200 && numeric < 300) ||
74
+ normalized === "ok" ||
75
+ normalized === "success" ||
76
+ normalized === "accepted" ||
77
+ normalized === "joined";
78
+ const rejected = (numeric !== undefined && numeric >= 400) ||
79
+ normalized === "error" ||
80
+ normalized === "failed" ||
81
+ normalized === "forbidden" ||
82
+ normalized === "unauthorized" ||
83
+ normalized === "rejected";
84
+ return { accepted, normalized, numeric, rejected };
85
+ });
86
+ const hasUnknownStatus = statuses.some((status) => !status.accepted && !status.rejected);
87
+ const explicitlyAccepted = record["ok"] === true ||
88
+ record["accepted"] === true ||
89
+ record["success"] === true ||
90
+ statuses.some((status) => status.accepted);
91
+ const cursorAliases = ["cursor", "recoveryCursor"];
92
+ const cursorValues = cursorAliases
93
+ .filter((alias) => Object.hasOwn(record, alias))
94
+ .map((alias) => record[alias]);
95
+ const cursorInvalid = cursorValues.some((cursorValue) => typeof cursorValue !== "string");
96
+ const cursorConflict = cursorValues.length > 1 &&
97
+ cursorValues.some((cursorValue) => cursorValue !== cursorValues[0]);
98
+ const recoveryAliases = ["recovered", "recoveryComplete"];
99
+ const recoveryDeclarations = recoveryAliases
100
+ .filter((alias) => Object.hasOwn(record, alias))
101
+ .map((alias) => record[alias]);
102
+ const recoveryInvalid = recoveryDeclarations.some((declaration) => typeof declaration !== "boolean");
103
+ const recoveryConflict = recoveryDeclarations.includes(true) && recoveryDeclarations.includes(false);
104
+ const normalizedError = typeof record["error"] === "string"
105
+ ? record["error"].trim().toLowerCase()
106
+ : undefined;
107
+ const explicitlyRejected = hasInvalidBooleanAlias ||
108
+ hasInvalidStatusAlias ||
109
+ record["ok"] === false ||
110
+ record["accepted"] === false ||
111
+ record["success"] === false ||
112
+ record["error"] !== undefined ||
113
+ cursorInvalid ||
114
+ cursorConflict ||
115
+ recoveryInvalid ||
116
+ recoveryConflict ||
117
+ (!reconnect && recoveryDeclarations.includes(false)) ||
118
+ hasUnknownStatus ||
119
+ statuses.some((status) => status.rejected);
120
+ const accepted = explicitlyAccepted && !explicitlyRejected;
121
+ const cursor = cursorValues[0];
122
+ const recoveryComplete = !reconnect ||
123
+ (recoveryDeclarations.length > 0 &&
124
+ recoveryDeclarations.every((declaration) => declaration === true));
125
+ const authenticationRejected = statuses.some(({ normalized, numeric }) => numeric === 401 || normalized === "unauthorized") || normalizedError === "unauthorized";
126
+ const authorizationRejected = statuses.some(({ normalized, numeric }) => numeric === 403 || normalized === "forbidden") || normalizedError === "forbidden";
127
+ return {
128
+ accepted,
129
+ empty: false,
130
+ ...(authenticationRejected
131
+ ? { rejectionCode: "authentication" }
132
+ : authorizationRejected
133
+ ? { rejectionCode: "authorization" }
134
+ : accepted
135
+ ? {}
136
+ : { rejectionCode: "validation" }),
137
+ recoveryComplete,
138
+ ...(typeof cursor === "string" ? { cursor } : {}),
139
+ ...(typeof record["message"] === "string"
140
+ ? { message: record["message"] }
141
+ : {}),
142
+ };
143
+ };
20
144
  const sameRun = (first, second) => first === second ||
21
145
  (first != null &&
22
146
  second != null &&
@@ -101,25 +225,65 @@ export const createSocketIoFactoryWith = (connect) => ({ getAuth, namespaceUrl,
101
225
  },
102
226
  });
103
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)]);
104
261
  export class TFRobotSocketClient {
105
- #active;
106
262
  #disposed = false;
107
- #establishmentAbort;
263
+ #establishing;
264
+ #pendingEstablishment;
108
265
  #factory;
109
266
  #namespaceUrl;
110
267
  #now;
111
268
  #options;
112
269
  #path;
270
+ #profile;
271
+ #currentServerRestLoader;
113
272
  #reconnectStatusLoader;
273
+ #recoveryCheckpoints = new Map();
114
274
  #runs = new Map();
115
- constructor(options, reconnectStatusLoader) {
275
+ #subscriptions = new Set();
276
+ #subscriptionGeneration = 0;
277
+ constructor(options, reconnectStatusLoader, currentServerRestLoader) {
116
278
  this.#options = options;
117
279
  this.#factory = options.socketFactory ?? createSocketIoFactory;
118
280
  this.#path = options.socketPath ?? "/socket.io";
119
281
  this.#now = options.now ?? Date.now;
282
+ this.#profile = resolveTFRobotServerProfile(options.serverProfile);
120
283
  this.#namespaceUrl =
121
284
  options.socketNamespaceUrl ?? this.#defaultNamespaceUrl(options.baseUrl);
122
285
  this.#reconnectStatusLoader = reconnectStatusLoader;
286
+ this.#currentServerRestLoader = currentServerRestLoader;
123
287
  }
124
288
  async subscribe(conversationId, options, observer) {
125
289
  if (this.#disposed) {
@@ -134,19 +298,29 @@ export class TFRobotSocketClient {
134
298
  error: createGatewayDeadlineExceededError(),
135
299
  };
136
300
  }
137
- this.#deactivateCurrent();
301
+ const generation = ++this.#subscriptionGeneration;
302
+ const subscriptionId = `tfrobot-subscription-${generation}`;
303
+ this.#cancelEstablishment();
138
304
  const establishmentAbort = new AbortController();
139
- this.#establishmentAbort = establishmentAbort;
140
- 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"), {
141
317
  deadlineAt: options.deadlineAt,
142
318
  now: this.#now,
143
319
  signal: establishmentAbort.signal,
144
320
  });
145
- if (this.#establishmentAbort === establishmentAbort) {
146
- this.#establishmentAbort = undefined;
147
- }
148
321
  switch (authOutcome.kind) {
149
322
  case "aborted": {
323
+ clearPendingEstablishment();
150
324
  return {
151
325
  ok: false,
152
326
  error: this.#error("conflict", this.#disposed
@@ -155,12 +329,14 @@ export class TFRobotSocketClient {
155
329
  };
156
330
  }
157
331
  case "deadline": {
332
+ clearPendingEstablishment();
158
333
  return {
159
334
  ok: false,
160
335
  error: createGatewayDeadlineExceededError(),
161
336
  };
162
337
  }
163
338
  case "error": {
339
+ clearPendingEstablishment();
164
340
  return {
165
341
  ok: false,
166
342
  error: this.#error("authentication", "Unable to obtain a TFRobot Socket session", true, undefined),
@@ -171,14 +347,43 @@ export class TFRobotSocketClient {
171
347
  }
172
348
  }
173
349
  if (this.#disposed || establishmentAbort.signal.aborted) {
350
+ clearPendingEstablishment();
174
351
  return {
175
352
  ok: false,
176
353
  error: this.#error("conflict", "TFRobot Gateway was disposed during subscription", false, undefined),
177
354
  };
178
355
  }
179
- 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));
180
384
  const errorConversationId = () => sanitizeCredentialText(conversationId, credentialValues);
181
- let firstAuth = authOutcome.value;
385
+ let firstAuth = initialAuth;
386
+ let currentServerSession = initialSession;
182
387
  let authenticationFailure;
183
388
  const connectionAbort = new AbortController();
184
389
  let socket;
@@ -195,20 +400,22 @@ export class TFRobotSocketClient {
195
400
  firstAuth = undefined;
196
401
  return auth;
197
402
  }
198
- const reconnectAuth = await awaitBounded(() => this.#getSocketAuth(conversationId, "reconnect"), {
403
+ const reconnectSession = await awaitBounded(() => this.#getSocketSession(conversationId, "reconnect"), {
199
404
  deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
200
405
  now: this.#now,
201
406
  signal: connectionAbort.signal,
202
407
  });
203
- if (reconnectAuth.kind === "value") {
204
- 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)) {
205
412
  credentialValues.add(value);
206
413
  }
207
- return reconnectAuth.value;
414
+ return reconnectAuth;
208
415
  }
209
- const reason = reconnectAuth.kind === "error"
210
- ? reconnectAuth.reason
211
- : new Error(reconnectAuth.kind === "deadline"
416
+ const reason = reconnectSession.kind === "error"
417
+ ? reconnectSession.reason
418
+ : new Error(reconnectSession.kind === "deadline"
212
419
  ? "TFRobot Socket session refresh exceeded its deadline"
213
420
  : "TFRobot Socket session refresh was cancelled");
214
421
  authenticationFailure = reason;
@@ -218,12 +425,27 @@ export class TFRobotSocketClient {
218
425
  }
219
426
  catch (reason) {
220
427
  connectionAbort.abort();
428
+ clearPendingEstablishment();
221
429
  return {
222
430
  ok: false,
223
431
  error: this.#transportError(reason, "Unable to create the TFRobot Socket transport", conversationId, credentialValues),
224
432
  };
225
433
  }
226
434
  const listeners = new Map();
435
+ let joinTimeout;
436
+ const activeErrorIds = new Map();
437
+ const replaceableErrorSources = new Set([
438
+ "authentication",
439
+ "connection",
440
+ "recovery",
441
+ "subscription",
442
+ ]);
443
+ let errorSequence = 0;
444
+ let publishUpdate = () => undefined;
445
+ const pendingNotifications = [];
446
+ const pendingRealtimeActions = [];
447
+ let joinPending = false;
448
+ let subscriptionEstablished = false;
227
449
  let setupFailure;
228
450
  const add = (eventName, listener) => {
229
451
  if (setupFailure !== undefined)
@@ -244,15 +466,52 @@ export class TFRobotSocketClient {
244
466
  // Diagnostics are observational and cannot break the chat stream.
245
467
  }
246
468
  };
247
- const report = (error) => {
248
- if (!active.active)
249
- return;
469
+ const notifyError = (error) => {
250
470
  try {
251
471
  observer.error?.(error);
252
472
  }
253
473
  catch {
254
474
  // Host observers are isolated from the transport listener.
255
475
  }
476
+ };
477
+ const resolveError = (source) => {
478
+ const errorId = activeErrorIds.get(source);
479
+ if (errorId === undefined || !active.active)
480
+ return;
481
+ activeErrorIds.delete(source);
482
+ publishUpdate(chatUpdateSchema.parse({
483
+ kind: "error.resolved",
484
+ conversationId: errorConversationId(),
485
+ errorId,
486
+ }));
487
+ };
488
+ const report = (error, source = "protocol") => {
489
+ if (!active.active)
490
+ return;
491
+ if (replaceableErrorSources.has(source))
492
+ resolveError(source);
493
+ const errorId = `${subscriptionId}:error:${++errorSequence}`;
494
+ if (replaceableErrorSources.has(source)) {
495
+ activeErrorIds.set(source, errorId);
496
+ }
497
+ const occurrenceError = error.conversationId === undefined
498
+ ? error
499
+ : { ...error, conversationId: errorConversationId() };
500
+ publishUpdate(chatUpdateSchema.parse({
501
+ kind: "error.reported",
502
+ conversationId: errorConversationId(),
503
+ error: occurrenceError,
504
+ errorId,
505
+ source,
506
+ scope: source === "domain"
507
+ ? { kind: "conversation", id: errorConversationId() }
508
+ : { kind: "subscription", id: subscriptionId },
509
+ generation,
510
+ }));
511
+ if (subscriptionEstablished)
512
+ notifyError(error);
513
+ else
514
+ pendingNotifications.push({ kind: "error", error });
256
515
  diagnose(error);
257
516
  };
258
517
  const sanitizePayload = (payload) => {
@@ -276,6 +535,10 @@ export class TFRobotSocketClient {
276
535
  updateConversationId !== errorConversationId()) {
277
536
  return;
278
537
  }
538
+ if (!subscriptionEstablished) {
539
+ pendingNotifications.push({ kind: "update", update });
540
+ return;
541
+ }
279
542
  try {
280
543
  observer.next(update);
281
544
  }
@@ -283,6 +546,80 @@ export class TFRobotSocketClient {
283
546
  diagnose(this.#error("unknown", "TFRobot Gateway observer rejected an update", false, errorConversationId()));
284
547
  }
285
548
  };
549
+ publishUpdate = next;
550
+ const dispatchRealtime = (action) => {
551
+ if (active.acceptingEvents)
552
+ action();
553
+ else if (joinPending)
554
+ pendingRealtimeActions.push(action);
555
+ };
556
+ const flushRealtime = () => {
557
+ for (const action of pendingRealtimeActions.splice(0))
558
+ action();
559
+ };
560
+ const lifecycle = (status, recovery, joinLatencyMs) => {
561
+ if (!active.active)
562
+ return;
563
+ const value = {
564
+ status,
565
+ generation,
566
+ reconnectAttempt: active.reconnectAttempt,
567
+ subscriptionId,
568
+ ...(recovery === undefined ? {} : { recovery }),
569
+ };
570
+ const update = chatUpdateSchema.parse({
571
+ kind: "lifecycle.changed",
572
+ conversationId: errorConversationId(),
573
+ lifecycle: value,
574
+ });
575
+ next(update);
576
+ try {
577
+ void Promise.resolve(this.#options.onLifecycleDiagnostic?.({
578
+ kind: "socket.lifecycle",
579
+ conversationId: errorConversationId(),
580
+ subscriptionId,
581
+ generation,
582
+ status,
583
+ reconnectAttempt: active.reconnectAttempt,
584
+ ...(joinLatencyMs === undefined ? {} : { joinLatencyMs }),
585
+ ...(recovery === undefined
586
+ ? {}
587
+ : {
588
+ recoveryComplete: recovery.complete,
589
+ ...(recovery.assurance === undefined
590
+ ? {}
591
+ : { recoveryAssurance: recovery.assurance }),
592
+ ...(recovery.cursor === undefined
593
+ ? {}
594
+ : { recoveryCursor: recovery.cursor }),
595
+ ...(recovery.reason === undefined
596
+ ? {}
597
+ : { recoveryReason: recovery.reason }),
598
+ ...(recovery.source === undefined
599
+ ? {}
600
+ : { recoverySource: recovery.source }),
601
+ }),
602
+ })).catch(() => undefined);
603
+ }
604
+ catch {
605
+ // Lifecycle diagnostics are observational.
606
+ }
607
+ };
608
+ const enterAuthRequired = () => {
609
+ active.acceptingEvents = false;
610
+ active.authRequired = true;
611
+ active.terminal = true;
612
+ joinPending = false;
613
+ pendingRealtimeActions.length = 0;
614
+ active.recoveryRevision += 1;
615
+ lifecycle("auth-required");
616
+ try {
617
+ active.socket.disconnect();
618
+ }
619
+ catch {
620
+ // Authentication is already terminal for this subscription episode.
621
+ }
622
+ };
286
623
  const mapAndNext = (invalidMessage, map) => {
287
624
  let update;
288
625
  try {
@@ -292,13 +629,87 @@ export class TFRobotSocketClient {
292
629
  report(this.#error("validation", invalidMessage, false, errorConversationId()));
293
630
  return;
294
631
  }
632
+ const recoveryIdentity = recoveryIdentityOfUpdate(update);
633
+ if (recoveryIdentity !== undefined) {
634
+ active.realtimeRevision += 1;
635
+ rememberBoundedMapValue(active.realtimeIdentityRevisions, recoveryIdentity, active.realtimeRevision);
636
+ const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
637
+ rememberBoundedSetValue(checkpoint, recoveryIdentity);
638
+ this.#recoveryCheckpoints.set(conversationId, checkpoint);
639
+ }
295
640
  next(update);
296
641
  };
642
+ const rebaseCurrentServer = async (recoveryRevision, runRevision, session, checkpoint, realtimeRevision) => {
643
+ if (this.#profile.kind !== "current-server") {
644
+ return { kind: "failure" };
645
+ }
646
+ const result = await this.#currentServerRestLoader({
647
+ checkpoint,
648
+ conversationId,
649
+ deadlineAt: this.#now() + this.#profile.rebase.deadlineMs,
650
+ limits: this.#profile.rebase,
651
+ session,
652
+ signal: connectionAbort.signal,
653
+ });
654
+ if (!active.active ||
655
+ !this.#subscriptions.has(active) ||
656
+ !active.socket.connected ||
657
+ active.recoveryRevision !== recoveryRevision) {
658
+ return { kind: "failure" };
659
+ }
660
+ if (!result.ok) {
661
+ const error = sanitizeCredentialError(result.error, credentialValues);
662
+ if (error.code === "authentication" || error.code === "authorization") {
663
+ report(error, "authentication");
664
+ return { kind: "auth-required" };
665
+ }
666
+ report(error, "recovery");
667
+ return { kind: "failure" };
668
+ }
669
+ const remembered = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
670
+ for (const update of result.value.updates) {
671
+ const identity = recoveryIdentityOfUpdate(update);
672
+ if (identity !== undefined &&
673
+ (active.realtimeIdentityRevisions.get(identity) ?? 0) >
674
+ realtimeRevision) {
675
+ continue;
676
+ }
677
+ next(update);
678
+ if (identity !== undefined)
679
+ rememberBoundedSetValue(remembered, identity);
680
+ }
681
+ this.#recoveryCheckpoints.set(conversationId, remembered);
682
+ if (active.runRevision === runRevision) {
683
+ const previous = this.#runs.get(conversationId);
684
+ this.#runs.set(conversationId, result.value.run);
685
+ if (!sameRun(previous, result.value.run)) {
686
+ next(chatUpdateSchema.parse({
687
+ kind: "run.replace",
688
+ conversationId: errorConversationId(),
689
+ run: result.value.run,
690
+ }));
691
+ }
692
+ }
693
+ const reason = result.value.checkpointReached
694
+ ? "rest-rebase-reached-checkpoint"
695
+ : result.value.boundedBy === undefined
696
+ ? "rest-rebase-history-exhausted"
697
+ : `rest-rebase-${result.value.boundedBy}`;
698
+ return { kind: "success", reason };
699
+ };
297
700
  let settleEstablishment;
298
- let connectedOnce = false;
299
701
  add("connect", () => {
300
702
  if (!active.active)
301
703
  return;
704
+ if (active.terminal) {
705
+ try {
706
+ active.socket.disconnect();
707
+ }
708
+ catch {
709
+ // Terminal subscriptions never rejoin.
710
+ }
711
+ return;
712
+ }
302
713
  if (settleEstablishment !== undefined &&
303
714
  isGatewayDeadlineExceeded(options, this.#now())) {
304
715
  settleEstablishment({
@@ -307,13 +718,263 @@ export class TFRobotSocketClient {
307
718
  });
308
719
  return;
309
720
  }
310
- const reconnect = connectedOnce;
311
- connectedOnce = true;
721
+ if (joinTimeout !== undefined) {
722
+ clearTimeout(joinTimeout);
723
+ joinTimeout = undefined;
724
+ }
725
+ const reconnect = subscriptionEstablished;
726
+ if (reconnect)
727
+ active.reconnectAttempt += 1;
728
+ const rebaseCheckpoint = new Set(this.#recoveryCheckpoints.get(conversationId) ?? []);
729
+ const rebaseRealtimeRevision = active.realtimeRevision;
312
730
  const recoveryRevision = ++active.recoveryRevision;
313
- try {
314
- socket.emit("join_conversation", {
315
- conversation_id: conversationId,
731
+ const runRevision = active.runRevision;
732
+ const joinStartedAt = this.#now();
733
+ active.acceptingEvents = false;
734
+ joinPending = true;
735
+ pendingRealtimeActions.length = 0;
736
+ lifecycle("joining");
737
+ let acknowledgementSettled = false;
738
+ const settleJoin = (...acknowledgementArguments) => {
739
+ if (!active.active ||
740
+ !this.#subscriptions.has(active) ||
741
+ active.recoveryRevision !== recoveryRevision) {
742
+ return;
743
+ }
744
+ if (acknowledgementSettled)
745
+ return;
746
+ acknowledgementSettled = true;
747
+ if (joinTimeout !== undefined) {
748
+ clearTimeout(joinTimeout);
749
+ joinTimeout = undefined;
750
+ }
751
+ let acknowledgement;
752
+ try {
753
+ const rawAcknowledgement = acknowledgementArguments[0];
754
+ acknowledgement = parseJoinAcknowledgement(rawAcknowledgement === undefined || rawAcknowledgement === true
755
+ ? rawAcknowledgement
756
+ : sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect, this.#profile.kind === "current-server");
757
+ }
758
+ catch {
759
+ acknowledgement = {
760
+ accepted: false,
761
+ empty: false,
762
+ recoveryComplete: false,
763
+ rejectionCode: "validation",
764
+ message: "Invalid TFRobot conversation subscription acknowledgement",
765
+ };
766
+ }
767
+ if (!acknowledgement.accepted) {
768
+ joinPending = false;
769
+ pendingRealtimeActions.length = 0;
770
+ const rejectionCode = acknowledgement.rejectionCode ?? "validation";
771
+ const error = this.#error(rejectionCode, acknowledgement.message ??
772
+ "TFRobot conversation subscription was rejected", false, errorConversationId());
773
+ if (settleEstablishment !== undefined) {
774
+ lifecycle("subscription-failed");
775
+ if (rejectionCode === "authentication" ||
776
+ rejectionCode === "authorization") {
777
+ void this.#invalidateSession(error, "rejected");
778
+ }
779
+ settleEstablishment({ ok: false, error });
780
+ }
781
+ else {
782
+ const authenticationRejected = rejectionCode === "authentication" ||
783
+ rejectionCode === "authorization";
784
+ if (authenticationRejected) {
785
+ enterAuthRequired();
786
+ }
787
+ else {
788
+ active.acceptingEvents = false;
789
+ active.terminal = true;
790
+ active.recoveryRevision += 1;
791
+ try {
792
+ active.socket.disconnect();
793
+ }
794
+ catch {
795
+ // Subscription failure is already terminal for this episode.
796
+ }
797
+ lifecycle("subscription-failed");
798
+ }
799
+ report(error, authenticationRejected ? "authentication" : "subscription");
800
+ if (authenticationRejected) {
801
+ void this.#invalidateSession(error, "rejected");
802
+ }
803
+ }
804
+ return;
805
+ }
806
+ if (!reconnect) {
807
+ joinPending = false;
808
+ subscriptionEstablished = true;
809
+ for (const notification of pendingNotifications.splice(0)) {
810
+ if (notification.kind === "update")
811
+ next(notification.update);
812
+ else
813
+ notifyError(notification.error);
814
+ }
815
+ settleEstablishment?.({
816
+ ok: true,
817
+ value: {
818
+ dispose: () => {
819
+ if (!active.active)
820
+ return;
821
+ active.active = false;
822
+ active.cleanup();
823
+ this.#subscriptions.delete(active);
824
+ },
825
+ },
826
+ });
827
+ resolveError("connection");
828
+ resolveError("authentication");
829
+ if (this.#profile.kind === "current-server" &&
830
+ acknowledgement.empty) {
831
+ lifecycle("degraded", {
832
+ assurance: "best-effort",
833
+ complete: false,
834
+ reason: "initial-rest-preflight",
835
+ source: "rest-rebase",
836
+ }, this.#now() - joinStartedAt);
837
+ }
838
+ else {
839
+ lifecycle("active", {
840
+ complete: true,
841
+ ...(acknowledgement.cursor === undefined
842
+ ? {}
843
+ : { cursor: acknowledgement.cursor }),
844
+ }, this.#now() - joinStartedAt);
845
+ }
846
+ currentServerSession = undefined;
847
+ active.acceptingEvents = true;
848
+ flushRealtime();
849
+ return;
850
+ }
851
+ joinPending = false;
852
+ const joinLatencyMs = this.#now() - joinStartedAt;
853
+ lifecycle("recovering", {
854
+ complete: false,
855
+ ...(acknowledgement.cursor === undefined
856
+ ? {}
857
+ : { cursor: acknowledgement.cursor }),
858
+ ...(acknowledgement.recoveryComplete
859
+ ? {}
860
+ : { reason: "server-replay-contract-unavailable" }),
861
+ }, joinLatencyMs);
862
+ active.acceptingEvents = true;
863
+ flushRealtime();
864
+ if (this.#profile.kind === "current-server" &&
865
+ !acknowledgement.recoveryComplete) {
866
+ const recoverySession = currentServerSession;
867
+ currentServerSession = undefined;
868
+ if (recoverySession === undefined) {
869
+ report(this.#error("authentication", "TFRobot reconnect did not provide a session for REST rebase", true, errorConversationId()), "recovery");
870
+ return;
871
+ }
872
+ void rebaseCurrentServer(recoveryRevision, runRevision, recoverySession, rebaseCheckpoint, rebaseRealtimeRevision)
873
+ .then((outcome) => {
874
+ if (!active.active ||
875
+ !this.#subscriptions.has(active) ||
876
+ !active.socket.connected ||
877
+ active.recoveryRevision !== recoveryRevision) {
878
+ return;
879
+ }
880
+ if (outcome.kind === "auth-required") {
881
+ enterAuthRequired();
882
+ return;
883
+ }
884
+ if (outcome.kind !== "success")
885
+ return;
886
+ resolveError("connection");
887
+ resolveError("recovery");
888
+ resolveError("authentication");
889
+ active.manualReconnectAttempted = false;
890
+ active.manualReconnectPending = false;
891
+ active.authRequired = false;
892
+ active.terminal = false;
893
+ lifecycle("degraded", {
894
+ assurance: "best-effort",
895
+ complete: false,
896
+ reason: outcome.reason,
897
+ source: "rest-rebase",
898
+ }, joinLatencyMs);
899
+ })
900
+ .catch(() => {
901
+ report(this.#error("unknown", "TFRobot REST rebase failed unexpectedly", true, errorConversationId()), "recovery");
902
+ });
903
+ return;
904
+ }
905
+ currentServerSession = undefined;
906
+ void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report)
907
+ .then((outcome) => {
908
+ if (outcome === "auth-required") {
909
+ enterAuthRequired();
910
+ return;
911
+ }
912
+ if ((outcome !== "success" && outcome !== "superseded-by-realtime") ||
913
+ !acknowledgement.recoveryComplete) {
914
+ return;
915
+ }
916
+ resolveError("connection");
917
+ resolveError("recovery");
918
+ resolveError("authentication");
919
+ active.manualReconnectAttempted = false;
920
+ active.manualReconnectPending = false;
921
+ active.authRequired = false;
922
+ active.terminal = false;
923
+ lifecycle("active", {
924
+ complete: true,
925
+ ...(acknowledgement.cursor === undefined
926
+ ? {}
927
+ : { cursor: acknowledgement.cursor }),
928
+ }, joinLatencyMs);
929
+ })
930
+ .catch(() => {
931
+ active.acceptingEvents = false;
932
+ active.terminal = true;
933
+ try {
934
+ active.socket.disconnect();
935
+ }
936
+ catch {
937
+ // Offline is already terminal for this subscription episode.
938
+ }
939
+ lifecycle("offline");
940
+ report(this.#error("unknown", "TFRobot reconnect reconciliation failed", true, errorConversationId()), "recovery");
316
941
  });
942
+ };
943
+ const joinDeadlineAt = reconnect
944
+ ? this.#now() + RECONNECT_JOIN_TIMEOUT_MS
945
+ : options.deadlineAt;
946
+ joinTimeout = setTimeout(() => {
947
+ joinTimeout = undefined;
948
+ if (!active.active || active.recoveryRevision !== recoveryRevision) {
949
+ return;
950
+ }
951
+ active.recoveryRevision += 1;
952
+ joinPending = false;
953
+ pendingRealtimeActions.length = 0;
954
+ const error = createGatewayDeadlineExceededError(errorConversationId());
955
+ if (settleEstablishment !== undefined) {
956
+ lifecycle("subscription-failed");
957
+ settleEstablishment({ ok: false, error });
958
+ }
959
+ else {
960
+ if (active.manualReconnectPending)
961
+ enterAuthRequired();
962
+ else {
963
+ active.acceptingEvents = false;
964
+ active.terminal = true;
965
+ lifecycle("subscription-failed");
966
+ }
967
+ report(error, active.manualReconnectPending ? "connection" : "subscription");
968
+ try {
969
+ active.socket.disconnect();
970
+ }
971
+ catch {
972
+ // The failed join attempt is already invalidated.
973
+ }
974
+ }
975
+ }, Math.min(MAX_TIMER_DELAY, Math.max(0, joinDeadlineAt - this.#now())));
976
+ try {
977
+ socket.emit("join_conversation", { conversation_id: conversationId }, settleJoin);
317
978
  }
318
979
  catch (reason) {
319
980
  const error = this.#transportError(reason, "Unable to join the TFRobot conversation", conversationId, credentialValues);
@@ -321,112 +982,97 @@ export class TFRobotSocketClient {
321
982
  settleEstablishment({ ok: false, error });
322
983
  }
323
984
  else {
324
- report(error);
985
+ report(error, "subscription");
325
986
  active.active = false;
326
987
  active.cleanup();
327
- if (this.#active === active)
328
- this.#active = undefined;
988
+ this.#subscriptions.delete(active);
329
989
  }
330
990
  return;
331
991
  }
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
992
  });
359
993
  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));
994
+ dispatchRealtime(() => {
995
+ if (belongsToForeignConversation(payload, conversationId))
996
+ return;
997
+ const parsed = messageDtoSchema.safeParse(sanitizePayload(payload));
998
+ if (!parsed.success) {
999
+ report(this.#error("validation", "Invalid TFRobot chat_message payload", false, errorConversationId()));
1000
+ return;
1001
+ }
1002
+ mapAndNext("TFRobot chat_message could not be normalized", () => mapMessageUpdate(parsed.data));
1003
+ });
368
1004
  });
369
1005
  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));
1006
+ dispatchRealtime(() => {
1007
+ if (belongsToForeignConversation(payload, conversationId))
1008
+ return;
1009
+ const parsed = eventDtoSchema.safeParse(sanitizePayload(payload));
1010
+ if (!parsed.success) {
1011
+ report(this.#error("validation", "Invalid TFRobot chat_event payload", false, errorConversationId()));
1012
+ return;
1013
+ }
1014
+ mapAndNext("TFRobot chat_event could not be normalized", () => mapEventUpdate(parsed.data));
1015
+ });
378
1016
  });
379
1017
  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,
1018
+ dispatchRealtime(() => {
1019
+ if (belongsToForeignConversation(payload, conversationId))
1020
+ return;
1021
+ const parsed = stateChangedDtoSchema.safeParse(sanitizePayload(payload));
1022
+ if (!parsed.success) {
1023
+ report(this.#error("validation", "Invalid TFRobot conversation_state_changed payload", false, errorConversationId()));
1024
+ return;
1025
+ }
1026
+ const targetConversationId = String(parsed.data.conversationId);
1027
+ if (targetConversationId !== conversationId &&
1028
+ targetConversationId !== errorConversationId()) {
1029
+ return;
1030
+ }
1031
+ active.runRevision += 1;
1032
+ mapAndNext("TFRobot run state could not be normalized", () => {
1033
+ const run = mapRun(targetConversationId, {
1034
+ working: parsed.data.state === "working",
1035
+ taskId: parsed.data.taskId,
1036
+ });
1037
+ this.#runs.set(targetConversationId, run);
1038
+ return chatUpdateSchema.parse({
1039
+ kind: "run.replace",
1040
+ conversationId: targetConversationId,
1041
+ run,
1042
+ });
403
1043
  });
404
1044
  });
405
1045
  });
406
1046
  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));
1047
+ dispatchRealtime(() => {
1048
+ if (belongsToForeignConversation(payload, conversationId))
1049
+ return;
1050
+ const parsed = chatErrorEventDtoSchema.safeParse(sanitizePayload(payload));
1051
+ if (!parsed.success) {
1052
+ report(this.#error("validation", "Invalid TFRobot chat_error payload", false, errorConversationId()));
1053
+ return;
1054
+ }
1055
+ const targetConversationId = String(parsed.data.conversationId);
1056
+ if (targetConversationId !== conversationId &&
1057
+ targetConversationId !== errorConversationId()) {
1058
+ return;
1059
+ }
1060
+ report(this.#error("server", typeof parsed.data.error === "string"
1061
+ ? parsed.data.error
1062
+ : "TFRobot run failed", false, targetConversationId, parsed.data, credentialValues), "domain");
1063
+ });
422
1064
  });
423
1065
  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));
1066
+ dispatchRealtime(() => {
1067
+ const parsed = socketProtocolErrorDtoSchema.safeParse(sanitizePayload(payload));
1068
+ report(this.#error("validation", parsed.success && typeof parsed.data.message === "string"
1069
+ ? parsed.data.message
1070
+ : "TFRobot Socket protocol error", false, conversationId, undefined, credentialValues));
1071
+ });
428
1072
  });
429
1073
  add("connect_error", (reason) => {
1074
+ if (!active.active || active.terminal)
1075
+ return;
430
1076
  const injectedError = chatErrorSchema.safeParse(reason);
431
1077
  const rejectionCode = socketAuthRejectionCode(reason);
432
1078
  const error = authenticationFailure !== undefined
@@ -448,23 +1094,82 @@ export class TFRobotSocketClient {
448
1094
  settleEstablishment({ ok: false, error });
449
1095
  return;
450
1096
  }
451
- report(error);
1097
+ if (active.manualReconnectPending) {
1098
+ enterAuthRequired();
1099
+ report(error, error.code === "authentication" || error.code === "authorization"
1100
+ ? "authentication"
1101
+ : "connection");
1102
+ return;
1103
+ }
1104
+ if (error.code === "authentication" || error.code === "authorization") {
1105
+ enterAuthRequired();
1106
+ }
1107
+ report(error, error.code === "authentication" || error.code === "authorization"
1108
+ ? "authentication"
1109
+ : "connection");
452
1110
  });
453
1111
  add("disconnect", (reason) => {
454
1112
  if (reason === "io client disconnect")
455
1113
  return;
1114
+ if (active.terminal)
1115
+ return;
1116
+ active.acceptingEvents = false;
1117
+ joinPending = false;
1118
+ pendingRealtimeActions.length = 0;
1119
+ if (joinTimeout !== undefined) {
1120
+ clearTimeout(joinTimeout);
1121
+ joinTimeout = undefined;
1122
+ }
456
1123
  active.recoveryRevision += 1;
1124
+ lifecycle("reconnecting");
457
1125
  const injectedError = chatErrorSchema.safeParse(reason);
458
1126
  report(injectedError.success
459
1127
  ? 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))
1128
+ : this.#error("network", `TFRobot Socket disconnected: ${String(reason)}`, true, conversationId, undefined, credentialValues), "connection");
1129
+ if (reason !== "io server disconnect")
464
1130
  return;
465
- if (belongsToForeignConversation(payload, conversationId))
1131
+ if (active.manualReconnectAttempted) {
1132
+ enterAuthRequired();
466
1133
  return;
467
- mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), sanitizePayload(payload), errorConversationId(), this.#now()));
1134
+ }
1135
+ active.manualReconnectAttempted = true;
1136
+ active.manualReconnectPending = true;
1137
+ const invalidationRevision = active.recoveryRevision;
1138
+ const invalidationError = this.#error("authentication", "TFRobot Socket session was invalidated by the server", true, errorConversationId());
1139
+ void awaitBounded(() => this.#invalidateSession(invalidationError, "unknown"), {
1140
+ deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
1141
+ now: this.#now,
1142
+ signal: connectionAbort.signal,
1143
+ }).then((outcome) => {
1144
+ if (!active.active ||
1145
+ active.socket.connected ||
1146
+ active.authRequired ||
1147
+ !active.manualReconnectPending ||
1148
+ active.recoveryRevision !== invalidationRevision) {
1149
+ return;
1150
+ }
1151
+ if (outcome.kind !== "value" || !outcome.value) {
1152
+ report(this.#error("authentication", "Unable to invalidate the TFRobot Socket session", false, errorConversationId()), "authentication");
1153
+ enterAuthRequired();
1154
+ return;
1155
+ }
1156
+ try {
1157
+ active.socket.connect();
1158
+ }
1159
+ catch (reconnectError) {
1160
+ report(this.#transportError(reconnectError, "Unable to reconnect the TFRobot Socket transport", conversationId, credentialValues), "authentication");
1161
+ enterAuthRequired();
1162
+ }
1163
+ });
1164
+ });
1165
+ const anyListener = (eventName, payload) => {
1166
+ dispatchRealtime(() => {
1167
+ if (KNOWN_EVENTS.has(eventName))
1168
+ return;
1169
+ if (belongsToForeignConversation(payload, conversationId))
1170
+ return;
1171
+ mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), sanitizePayload(payload), errorConversationId(), this.#now()));
1172
+ });
468
1173
  };
469
1174
  if (setupFailure === undefined) {
470
1175
  try {
@@ -476,6 +1181,10 @@ export class TFRobotSocketClient {
476
1181
  }
477
1182
  const cleanup = () => {
478
1183
  connectionAbort.abort();
1184
+ if (joinTimeout !== undefined) {
1185
+ clearTimeout(joinTimeout);
1186
+ joinTimeout = undefined;
1187
+ }
479
1188
  for (const [eventName, listener] of listeners) {
480
1189
  try {
481
1190
  socket.off(eventName, listener);
@@ -497,18 +1206,24 @@ export class TFRobotSocketClient {
497
1206
  // Adapter ownership is cleared even if an injected transport misbehaves.
498
1207
  }
499
1208
  firstAuth = undefined;
1209
+ currentServerSession = undefined;
500
1210
  credentialValues.clear();
1211
+ pendingNotifications.length = 0;
1212
+ pendingRealtimeActions.length = 0;
501
1213
  };
502
1214
  if (setupFailure !== undefined) {
503
1215
  const error = this.#transportError(setupFailure.reason, "Unable to configure the TFRobot Socket transport", conversationId, credentialValues);
504
1216
  cleanup();
1217
+ clearPendingEstablishment();
505
1218
  return {
506
1219
  ok: false,
507
1220
  error,
508
1221
  };
509
1222
  }
510
1223
  const active = {
1224
+ acceptingEvents: true,
511
1225
  active: true,
1226
+ authRequired: false,
512
1227
  cancelEstablishment: () => {
513
1228
  settleEstablishment?.({
514
1229
  ok: false,
@@ -517,11 +1232,21 @@ export class TFRobotSocketClient {
517
1232
  },
518
1233
  cleanup,
519
1234
  conversationId,
1235
+ generation,
1236
+ manualReconnectAttempted: false,
1237
+ manualReconnectPending: false,
520
1238
  observer,
1239
+ reconnectAttempt: 0,
1240
+ realtimeIdentityRevisions: new Map(),
1241
+ realtimeRevision: 0,
521
1242
  recoveryRevision: 0,
1243
+ runRevision: 0,
522
1244
  socket,
1245
+ subscriptionId,
1246
+ terminal: false,
523
1247
  };
524
- this.#active = active;
1248
+ this.#subscriptions.add(active);
1249
+ this.#establishing = active;
525
1250
  return new Promise((resolve) => {
526
1251
  let settled = false;
527
1252
  const settle = (result) => {
@@ -529,12 +1254,14 @@ export class TFRobotSocketClient {
529
1254
  return;
530
1255
  settled = true;
531
1256
  settleEstablishment = undefined;
1257
+ clearPendingEstablishment();
1258
+ if (this.#establishing === active)
1259
+ this.#establishing = undefined;
532
1260
  clearTimeout(timeout);
533
1261
  if (!result.ok) {
534
1262
  active.active = false;
535
1263
  active.cleanup();
536
- if (this.#active === active)
537
- this.#active = undefined;
1264
+ this.#subscriptions.delete(active);
538
1265
  }
539
1266
  resolve(result);
540
1267
  };
@@ -553,6 +1280,7 @@ export class TFRobotSocketClient {
553
1280
  return;
554
1281
  }
555
1282
  try {
1283
+ lifecycle("connecting");
556
1284
  socket.connect();
557
1285
  }
558
1286
  catch (reason) {
@@ -574,24 +1302,54 @@ export class TFRobotSocketClient {
574
1302
  if (this.#disposed)
575
1303
  return;
576
1304
  this.#disposed = true;
577
- this.#deactivateCurrent();
1305
+ this.#cancelEstablishment();
1306
+ for (const subscription of this.#subscriptions) {
1307
+ subscription.active = false;
1308
+ subscription.cleanup();
1309
+ }
1310
+ this.#subscriptions.clear();
1311
+ this.#recoveryCheckpoints.clear();
578
1312
  this.#runs.clear();
579
1313
  }
1314
+ rememberSnapshot(snapshot) {
1315
+ if (this.#disposed)
1316
+ return;
1317
+ const conversationId = snapshot.conversation.id;
1318
+ this.#runs.set(conversationId, snapshot.run);
1319
+ const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
1320
+ for (const identity of recoveryIdentitiesOfSnapshot(snapshot)) {
1321
+ rememberBoundedSetValue(checkpoint, identity);
1322
+ }
1323
+ this.#recoveryCheckpoints.set(conversationId, checkpoint);
1324
+ }
580
1325
  rememberRun(conversationId, run) {
581
1326
  if (this.#disposed)
582
1327
  return;
583
1328
  this.#runs.set(conversationId, run);
584
1329
  }
585
- #deactivateCurrent() {
586
- this.#establishmentAbort?.abort();
587
- this.#establishmentAbort = undefined;
588
- const current = this.#active;
589
- if (current === undefined)
1330
+ forgetConversation(conversationId) {
1331
+ if (this.#disposed)
590
1332
  return;
591
- current.cancelEstablishment();
592
- current.active = false;
593
- current.cleanup();
594
- this.#active = undefined;
1333
+ if (this.#pendingEstablishment?.conversationId === conversationId ||
1334
+ this.#establishing?.conversationId === conversationId) {
1335
+ this.#cancelEstablishment();
1336
+ }
1337
+ for (const subscription of [...this.#subscriptions]) {
1338
+ if (subscription.conversationId !== conversationId)
1339
+ continue;
1340
+ subscription.active = false;
1341
+ subscription.recoveryRevision += 1;
1342
+ subscription.cleanup();
1343
+ this.#subscriptions.delete(subscription);
1344
+ }
1345
+ this.#recoveryCheckpoints.delete(conversationId);
1346
+ this.#runs.delete(conversationId);
1347
+ }
1348
+ #cancelEstablishment() {
1349
+ this.#pendingEstablishment?.abort.abort();
1350
+ this.#pendingEstablishment = undefined;
1351
+ this.#establishing?.cancelEstablishment();
1352
+ this.#establishing = undefined;
595
1353
  }
596
1354
  #defaultNamespaceUrl(baseUrl) {
597
1355
  const url = new URL(baseUrl);
@@ -600,7 +1358,7 @@ export class TFRobotSocketClient {
600
1358
  url.hash = "";
601
1359
  return url.toString().replace(/\/$/u, "");
602
1360
  }
603
- async #getSocketAuth(conversationId, purpose) {
1361
+ async #getSocketSession(conversationId, purpose) {
604
1362
  const session = await this.#options.sessionProvider.getSession({
605
1363
  purpose,
606
1364
  operation: "subscribe",
@@ -609,19 +1367,30 @@ export class TFRobotSocketClient {
609
1367
  if (!isValidTFRobotSession(session)) {
610
1368
  throw new TypeError("SessionProvider returned invalid TFRobot credentials");
611
1369
  }
612
- return authOf(session);
1370
+ return session;
613
1371
  }
614
- async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, next, report) {
1372
+ async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report) {
615
1373
  const result = await this.#reconnectStatusLoader(conversationId);
616
1374
  if (!active.active ||
617
- this.#active !== active ||
1375
+ !this.#subscriptions.has(active) ||
618
1376
  !active.socket.connected ||
619
1377
  active.recoveryRevision !== recoveryRevision) {
620
- return;
1378
+ return "failure";
621
1379
  }
622
1380
  if (!result.ok) {
623
- report(sanitizeCredentialError(result.error, credentialValues));
624
- return;
1381
+ const error = sanitizeCredentialError(result.error, credentialValues);
1382
+ if (error.code === "authentication" || error.code === "authorization") {
1383
+ report(error, "authentication");
1384
+ return "auth-required";
1385
+ }
1386
+ if (active.runRevision !== runRevision) {
1387
+ return "superseded-by-realtime";
1388
+ }
1389
+ report(error, "recovery");
1390
+ return "failure";
1391
+ }
1392
+ if (active.runRevision !== runRevision) {
1393
+ return "superseded-by-realtime";
625
1394
  }
626
1395
  try {
627
1396
  const updateConversationId = sanitizeCredentialText(conversationId, credentialValues);
@@ -629,16 +1398,18 @@ export class TFRobotSocketClient {
629
1398
  const run = mapRun(updateConversationId, safeStatus);
630
1399
  const previous = this.#runs.get(conversationId);
631
1400
  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
- }));
1401
+ if (!sameRun(previous, run)) {
1402
+ next(chatUpdateSchema.parse({
1403
+ kind: "run.replace",
1404
+ conversationId: updateConversationId,
1405
+ run,
1406
+ }));
1407
+ }
1408
+ return "success";
639
1409
  }
640
1410
  catch {
641
- report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId, undefined, credentialValues));
1411
+ report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId, undefined, credentialValues), "recovery");
1412
+ return "failure";
642
1413
  }
643
1414
  }
644
1415
  async #invalidateSession(error, reason) {
@@ -647,9 +1418,10 @@ export class TFRobotSocketClient {
647
1418
  reason: reason ?? (error.code === "authorization" ? "forbidden" : "expired"),
648
1419
  error,
649
1420
  });
1421
+ return true;
650
1422
  }
651
1423
  catch {
652
- // Host refresh/login failures must not replace the Socket error.
1424
+ return false;
653
1425
  }
654
1426
  }
655
1427
  #transportError(reason, fallback, conversationId, credentialValues = []) {