openai-ws-opencode 0.1.28 → 0.1.29

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.
@@ -9,10 +9,14 @@ let WebSocketImpl = loadDefaultWebSocketConstructor();
9
9
  export const connectionPool = [];
10
10
  const turnStateByContext = new Map();
11
11
  const lastResponseIDByContext = new Map();
12
+ const contextEpochByKey = new Map();
13
+ const contextOwnerByKey = new Map();
12
14
  const scopeCooldowns = new Map();
13
15
  const authEndpoints = new Map();
16
+ const OAUTH_MISSING_ACCOUNT_SEED = "codex-oauth-missing-account";
14
17
  let nextConnectionID = 1;
15
18
  let nextStreamID = 1;
19
+ let contextEpochClock = 1;
16
20
  export const readyState = {
17
21
  CONNECTING: 0,
18
22
  OPEN: 1,
@@ -39,14 +43,25 @@ function authScopeHash(headers) {
39
43
  .join("\n");
40
44
  return crypto.createHash("sha256").update(auth).digest("hex");
41
45
  }
42
- function accountFingerprint(headers) {
46
+ function accountFingerprint(headers, wsUrl) {
43
47
  const account = headers["ChatGPT-Account-ID"] ?? headers["ChatGPT-Account-Id"] ?? headers["chatgpt-account-id"] ?? "";
44
48
  if (account) {
45
49
  return crypto.createHash("sha256").update(`acct:${account}`).digest("hex").slice(0, 32);
46
50
  }
51
+ // Codex OAuth without account id: stable shared sentinel so bearer rotation cannot bypass account caps.
52
+ if (providerForUrl(wsUrl) === "codex") {
53
+ return crypto.createHash("sha256").update(`acct:${OAUTH_MISSING_ACCOUNT_SEED}`).digest("hex").slice(0, 32);
54
+ }
55
+ // Public API keys: fingerprint by auth material.
47
56
  const auth = headers.Authorization ?? headers.authorization ?? "";
48
57
  return crypto.createHash("sha256").update(`auth:${auth}`).digest("hex").slice(0, 32);
49
58
  }
59
+ function authGenerationKey(endpoint, accountFp) {
60
+ return `${endpoint}::${accountFp}`;
61
+ }
62
+ function hashIdentity(kind, value) {
63
+ return crypto.createHash("sha256").update(`${kind}:${value}`).digest("hex").slice(0, 32);
64
+ }
50
65
  function breakerDomainKey(provider, origin, accountFp) {
51
66
  return crypto.createHash("sha256").update(`${provider}\n${origin}\n${accountFp}`).digest("hex");
52
67
  }
@@ -60,7 +75,92 @@ function scopeKey(wsUrl, headers) {
60
75
  return `${wsUrl}::${authScopeHash(headers)}`;
61
76
  }
62
77
  function contextScopeKey(wsUrl, headers, context) {
63
- return `${scopeKey(wsUrl, headers)}::session:${context.sessionID ?? ""}::agent:${context.agent ?? ""}`;
78
+ const sessionHash = context.sessionID ? hashIdentity("session", context.sessionID) : "";
79
+ const agentHash = context.agent ? hashIdentity("agent", context.agent) : "";
80
+ return `${scopeKey(wsUrl, headers)}::ctx:${sessionHash}::agt:${agentHash}`;
81
+ }
82
+ function contextKeyMatchesSession(contextKey, sessionID) {
83
+ const sessionHash = hashIdentity("session", sessionID);
84
+ return contextKey.includes(`::ctx:${sessionHash}::`);
85
+ }
86
+ function contextEntryCount() {
87
+ const keys = new Set();
88
+ for (const key of turnStateByContext.keys())
89
+ keys.add(key);
90
+ for (const key of lastResponseIDByContext.keys())
91
+ keys.add(key);
92
+ for (const key of contextEpochByKey.keys())
93
+ keys.add(key);
94
+ for (const key of contextOwnerByKey.keys())
95
+ keys.add(key);
96
+ return keys.size;
97
+ }
98
+ function scrubIdleConnectionContext(contextKey) {
99
+ for (const conn of connectionPool) {
100
+ if (conn.contextKey !== contextKey || conn.busy)
101
+ continue;
102
+ conn.lastResponseID = undefined;
103
+ conn.turnState = undefined;
104
+ }
105
+ }
106
+ function boundContextMaps() {
107
+ const max = transportConfig.maxAccountingEntries;
108
+ while (contextEntryCount() > max) {
109
+ const oldest = contextEpochByKey.keys().next().value ??
110
+ turnStateByContext.keys().next().value ??
111
+ lastResponseIDByContext.keys().next().value ??
112
+ contextOwnerByKey.keys().next().value;
113
+ if (oldest === undefined)
114
+ break;
115
+ turnStateByContext.delete(oldest);
116
+ lastResponseIDByContext.delete(oldest);
117
+ contextEpochByKey.delete(oldest);
118
+ contextOwnerByKey.delete(oldest);
119
+ // Evicted map keys must not be reintroduced from idle local connection state.
120
+ scrubIdleConnectionContext(oldest);
121
+ }
122
+ }
123
+ function currentContextEpoch(contextKey) {
124
+ return contextEpochByKey.get(contextKey) ?? 0;
125
+ }
126
+ function bumpContextEpoch(contextKey) {
127
+ const next = Math.max(contextEpochClock, currentContextEpoch(contextKey) + 1);
128
+ contextEpochClock = next + 1;
129
+ contextEpochByKey.delete(contextKey);
130
+ contextEpochByKey.set(contextKey, next);
131
+ boundContextMaps();
132
+ return next;
133
+ }
134
+ function setContextMapValue(map, contextKey, value) {
135
+ map.delete(contextKey);
136
+ map.set(contextKey, value);
137
+ if (!contextEpochByKey.has(contextKey)) {
138
+ contextEpochByKey.set(contextKey, currentContextEpoch(contextKey));
139
+ }
140
+ else {
141
+ const epoch = contextEpochByKey.get(contextKey);
142
+ contextEpochByKey.delete(contextKey);
143
+ contextEpochByKey.set(contextKey, epoch);
144
+ }
145
+ boundContextMaps();
146
+ }
147
+ function clearContextAccounting(contextKey, options = {}) {
148
+ turnStateByContext.delete(contextKey);
149
+ lastResponseIDByContext.delete(contextKey);
150
+ contextOwnerByKey.delete(contextKey);
151
+ if (options.bumpEpoch !== false)
152
+ bumpContextEpoch(contextKey);
153
+ else
154
+ boundContextMaps();
155
+ }
156
+ function canPublishContextState(conn) {
157
+ if (conn.staleAuth || !isCurrentAuth(conn))
158
+ return false;
159
+ return conn.contextEpoch === currentContextEpoch(conn.contextKey);
160
+ }
161
+ function admitContextTurn(conn) {
162
+ conn.contextEpoch = currentContextEpoch(conn.contextKey);
163
+ setContextMapValue(contextOwnerByKey, conn.contextKey, conn.id);
64
164
  }
65
165
  function headersForConnection(wsUrl, baseHeaders, context, key) {
66
166
  const headers = { ...baseHeaders };
@@ -143,11 +243,12 @@ function boundAuthEndpoints() {
143
243
  authEndpoints.delete(oldest);
144
244
  }
145
245
  }
146
- function bindAuthGeneration(endpoint, hash) {
147
- let state = authEndpoints.get(endpoint);
246
+ function bindAuthGeneration(endpoint, accountFp, hash) {
247
+ const key = authGenerationKey(endpoint, accountFp);
248
+ let state = authEndpoints.get(key);
148
249
  if (!state) {
149
250
  state = { currentGeneration: 1, currentHash: hash, bindings: new Map([[hash, 1]]), order: [hash] };
150
- authEndpoints.set(endpoint, state);
251
+ authEndpoints.set(key, state);
151
252
  boundAuthEndpoints();
152
253
  return 1;
153
254
  }
@@ -171,13 +272,17 @@ function bindAuthGeneration(endpoint, hash) {
171
272
  }
172
273
  state.bindings.delete(oldest);
173
274
  }
275
+ // Refresh map insertion order for bounded eviction fairness.
276
+ authEndpoints.delete(key);
277
+ authEndpoints.set(key, state);
278
+ boundAuthEndpoints();
174
279
  return state.currentGeneration;
175
280
  }
176
- function currentAuthGeneration(endpoint) {
177
- return authEndpoints.get(endpoint)?.currentGeneration ?? 0;
281
+ function currentAuthGeneration(endpoint, accountFp) {
282
+ return authEndpoints.get(authGenerationKey(endpoint, accountFp))?.currentGeneration ?? 0;
178
283
  }
179
284
  function isCurrentAuth(conn) {
180
- return conn.authGeneration === currentAuthGeneration(conn.endpointKey);
285
+ return conn.authGeneration === currentAuthGeneration(conn.endpointKey, conn.accountFp);
181
286
  }
182
287
  function finishConnectPermit(conn, result) {
183
288
  if (!conn.connectPermit || conn.connectFinished)
@@ -554,8 +659,10 @@ function clearLastResponseID(conn) {
554
659
  lastResponseIDByContext.delete(conn.contextKey);
555
660
  }
556
661
  function persistLastResponseID(conn, responseID) {
662
+ if (!canPublishContextState(conn))
663
+ return;
557
664
  conn.lastResponseID = responseID;
558
- lastResponseIDByContext.set(conn.contextKey, responseID);
665
+ setContextMapValue(lastResponseIDByContext, conn.contextKey, responseID);
559
666
  }
560
667
  function frameResponseID(frame) {
561
668
  let responseID;
@@ -805,6 +912,7 @@ function connect(conn) {
805
912
  return;
806
913
  conn.lastCloseCode = 1006;
807
914
  conn.lastCloseReason = "connection timed out";
915
+ conn.sawConnectTimeout = true;
808
916
  finishConnectPermit(conn, { success: false, breakerEligible: true, timeout: true });
809
917
  handleSocketLoss(conn);
810
918
  }, transportConfig.connectTimeoutMs);
@@ -822,14 +930,32 @@ function connect(conn) {
822
930
  if (generation !== conn.generation)
823
931
  return;
824
932
  const turnState = headerValue(response, X_CODEX_TURN_STATE_HEADER);
825
- if (turnState) {
933
+ if (turnState && canPublishContextState(conn)) {
826
934
  conn.turnState = turnState;
827
- turnStateByContext.set(conn.contextKey, turnState);
935
+ setContextMapValue(turnStateByContext, conn.contextKey, turnState);
828
936
  }
829
937
  conn.serverReasoningIncluded = headerValue(response, X_REASONING_INCLUDED_HEADER) !== undefined;
830
938
  conn.modelsEtag = headerValue(response, X_MODELS_ETAG_HEADER);
831
939
  conn.serverModel = headerValue(response, OPENAI_MODEL_HEADER);
832
940
  };
941
+ const rejectInboundOverflow = (message, probeBytes) => {
942
+ const streamKey = conn.pending?.streamKey ?? `overflow:${conn.id}`;
943
+ // Non-mutating reject path: oversized probe increments queueOverflows and retains zero bytes.
944
+ getTransportCoordinator().adjustQueue({
945
+ provider: conn.provider,
946
+ streamKey,
947
+ bytes: probeBytes,
948
+ messages: 1,
949
+ });
950
+ fail(conn, transportError({
951
+ code: "queue_overflow",
952
+ phase: "active",
953
+ message,
954
+ fallbackEligible: false,
955
+ breakerEligible: false,
956
+ safeToReplay: false,
957
+ }), true);
958
+ };
833
959
  const handleMessage = (data, isBinary) => {
834
960
  if (generation !== conn.generation)
835
961
  return;
@@ -838,7 +964,23 @@ function connect(conn) {
838
964
  fail(conn, new Error("unexpected binary websocket event"), true);
839
965
  return;
840
966
  }
967
+ const decoded = decodeFrameData(data);
968
+ const maxBytes = transportConfig.queueMaxBytesPerStream;
969
+ const incomingBytes = Buffer.byteLength(decoded, "utf8");
970
+ if (incomingBytes > maxBytes) {
971
+ rejectInboundOverflow("OpenAI WebSocket inbound frame queue overflow", incomingBytes);
972
+ return;
973
+ }
974
+ const carryBytes = Buffer.byteLength(conn.frameCarryover, "utf8");
975
+ if (carryBytes + incomingBytes > maxBytes) {
976
+ rejectInboundOverflow("OpenAI WebSocket inbound carryover queue overflow", carryBytes + incomingBytes);
977
+ return;
978
+ }
841
979
  const parsed = parseFrames(data, conn.frameCarryover);
980
+ if (Buffer.byteLength(parsed.carryover, "utf8") > maxBytes) {
981
+ rejectInboundOverflow("OpenAI WebSocket inbound carryover queue overflow", Buffer.byteLength(parsed.carryover, "utf8"));
982
+ return;
983
+ }
842
984
  conn.frameCarryover = parsed.carryover;
843
985
  for (const frame of parsed.frames)
844
986
  enqueueSSE(conn, frame);
@@ -847,6 +989,7 @@ function connect(conn) {
847
989
  if (generation !== conn.generation)
848
990
  return;
849
991
  conn.lastErrorMessage = messageFromError(error) ?? "unknown websocket error";
992
+ conn.sawConnectNonTimeout = true;
850
993
  finishConnectPermit(conn, { success: false, breakerEligible: true });
851
994
  handleSocketLoss(conn);
852
995
  };
@@ -860,6 +1003,8 @@ function connect(conn) {
860
1003
  else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined) {
861
1004
  conn.lastCloseReason = "socket closed without close code";
862
1005
  }
1006
+ if (conn.lastCloseReason !== "connection timed out")
1007
+ conn.sawConnectNonTimeout = true;
863
1008
  finishConnectPermit(conn, { success: false, breakerEligible: true });
864
1009
  handleSocketLoss(conn);
865
1010
  };
@@ -871,6 +1016,7 @@ function connect(conn) {
871
1016
  conn.lastCloseCode = status;
872
1017
  conn.retryAfterMs = parseRetryAfterMs(response);
873
1018
  conn.lastCloseReason = `websocket handshake failed${status !== undefined ? ` with status ${status}` : ""}`;
1019
+ conn.sawConnectNonTimeout = true;
874
1020
  finishConnectPermit(conn, { success: false, breakerEligible: true });
875
1021
  handleSocketLoss(conn);
876
1022
  };
@@ -879,7 +1025,7 @@ function connect(conn) {
879
1025
  try {
880
1026
  ws = new WebSocketImpl(conn.wsUrl, {
881
1027
  headers: conn.headers,
882
- perMessageDeflate: true,
1028
+ perMessageDeflate: false,
883
1029
  finishRequest(request) {
884
1030
  if (attachUpgrade)
885
1031
  request.on?.("upgrade", handleUpgrade);
@@ -889,6 +1035,7 @@ function connect(conn) {
889
1035
  }
890
1036
  catch (error) {
891
1037
  conn.lastErrorMessage = messageFromError(error) ?? "websocket constructor failed";
1038
+ conn.sawConnectNonTimeout = true;
892
1039
  finishConnectPermit(conn, { success: false, breakerEligible: true });
893
1040
  queueMicrotask(() => handleSocketLoss(conn));
894
1041
  return;
@@ -964,12 +1111,24 @@ function handleSocketLoss(conn) {
964
1111
  if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted) {
965
1112
  return;
966
1113
  }
1114
+ if (conn.staleAuth || !isCurrentAuth(conn)) {
1115
+ getTransportCoordinator().noteAuthStale();
1116
+ fail(conn, transportError({
1117
+ code: "stale_auth",
1118
+ phase: "pre_send",
1119
+ message: "OpenAI WebSocket auth generation is stale; refusing reconnect",
1120
+ fallbackEligible: true,
1121
+ safeToReplay: true,
1122
+ }), true);
1123
+ return;
1124
+ }
967
1125
  connect(conn);
968
1126
  }, retryDelay(conn.reconnectAttempts, retryAfterMs)));
969
1127
  return;
970
1128
  }
1129
+ const allTimeouts = conn.sawConnectTimeout && !conn.sawConnectNonTimeout;
971
1130
  fail(conn, transportError({
972
- code: "connect_failure",
1131
+ code: allTimeouts ? "handshake_timeout" : "connect_failure",
973
1132
  phase: "handshake",
974
1133
  message: formatFailureMessage(conn, "retry limit reached before response.create was sent"),
975
1134
  fallbackEligible: true,
@@ -987,7 +1146,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
987
1146
  const endpoint = endpointKey(wsUrl);
988
1147
  const provider = providerForUrl(wsUrl);
989
1148
  const origin = canonicalWebSocketOrigin(wsUrl);
990
- const accountFp = accountFingerprint(headers);
1149
+ const accountFp = accountFingerprint(headers, wsUrl);
991
1150
  const now = Date.now();
992
1151
  const conn = {
993
1152
  id: `ws-${nextConnectionID++}`,
@@ -1001,7 +1160,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
1001
1160
  accountFp,
1002
1161
  provider,
1003
1162
  origin,
1004
- authGeneration: bindAuthGeneration(endpoint, hash),
1163
+ authGeneration: bindAuthGeneration(endpoint, accountFp, hash),
1005
1164
  busy: false,
1006
1165
  warm,
1007
1166
  staleAuth: false,
@@ -1025,6 +1184,9 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
1025
1184
  turnCount: 0,
1026
1185
  intent: warm ? "prewarm" : "demand",
1027
1186
  breakerDomain: breakerDomainKey(provider, origin, accountFp),
1187
+ contextEpoch: currentContextEpoch(key),
1188
+ sawConnectTimeout: false,
1189
+ sawConnectNonTimeout: false,
1028
1190
  };
1029
1191
  connectionPool.push(conn);
1030
1192
  getTransportCoordinator().adjustSessions(1);
@@ -1055,20 +1217,25 @@ function activeBusy(key) {
1055
1217
  }
1056
1218
  export function invalidateStaleAuthConnections(wsUrl, headers) {
1057
1219
  const endpoint = endpointKey(wsUrl);
1220
+ const accountFp = accountFingerprint(headers, wsUrl);
1058
1221
  const hash = authScopeHash(headers);
1059
- const state = authEndpoints.get(endpoint);
1222
+ const authKey = authGenerationKey(endpoint, accountFp);
1223
+ const state = authEndpoints.get(authKey);
1060
1224
  // ABA: presenting an old remembered binding must not become current or stale newer sockets.
1061
1225
  if (state && hash !== state.currentHash && state.bindings.has(hash))
1062
1226
  return;
1063
- const generation = bindAuthGeneration(endpoint, hash);
1064
- const keepHash = authEndpoints.get(endpoint)?.currentHash ?? hash;
1065
- const keepGen = authEndpoints.get(endpoint)?.currentGeneration ?? generation;
1227
+ const generation = bindAuthGeneration(endpoint, accountFp, hash);
1228
+ const keepHash = authEndpoints.get(authKey)?.currentHash ?? hash;
1229
+ const keepGen = authEndpoints.get(authKey)?.currentGeneration ?? generation;
1066
1230
  for (const conn of [...connectionPool]) {
1067
1231
  if (conn.wsUrl !== wsUrl)
1068
1232
  continue;
1233
+ if (conn.accountFp !== accountFp)
1234
+ continue;
1069
1235
  if (conn.scopeHash === keepHash && conn.authGeneration === keepGen)
1070
1236
  continue;
1071
1237
  conn.staleAuth = true;
1238
+ clearContextAccounting(conn.contextKey);
1072
1239
  if (conn.busy)
1073
1240
  continue;
1074
1241
  remove(conn);
@@ -1124,8 +1291,9 @@ function reserveConnection(wsUrl, headers, context) {
1124
1291
  }
1125
1292
  const endpoint = endpointKey(wsUrl);
1126
1293
  const hash = authScopeHash(headers);
1127
- const generation = bindAuthGeneration(endpoint, hash);
1128
- if (generation !== currentAuthGeneration(endpoint)) {
1294
+ const accountFp = accountFingerprint(headers, wsUrl);
1295
+ const generation = bindAuthGeneration(endpoint, accountFp, hash);
1296
+ if (generation !== currentAuthGeneration(endpoint, accountFp)) {
1129
1297
  coordinator.noteAuthStale();
1130
1298
  admitMiss("stale_auth", "OpenAI WebSocket auth generation is stale");
1131
1299
  }
@@ -1137,10 +1305,13 @@ function reserveConnection(wsUrl, headers, context) {
1137
1305
  reusable.warm = false;
1138
1306
  reusable.intent = "demand";
1139
1307
  reusable.turnCount += 1;
1308
+ // Always take the bounded map as source of truth, including absences after clear/eviction.
1309
+ reusable.lastResponseID = lastResponseIDByContext.get(key);
1310
+ reusable.turnState = turnStateByContext.get(key);
1311
+ admitContextTurn(reusable);
1140
1312
  coordinator.noteLeaseAdmission();
1141
1313
  return reusable;
1142
1314
  }
1143
- const accountFp = accountFingerprint(headers);
1144
1315
  const provider = providerForUrl(wsUrl);
1145
1316
  const origin = canonicalWebSocketOrigin(wsUrl);
1146
1317
  const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "demand" });
@@ -1161,17 +1332,18 @@ function reserveConnection(wsUrl, headers, context) {
1161
1332
  const conn = create(wsUrl, headers, context, false, started.permit);
1162
1333
  conn.busy = true;
1163
1334
  conn.turnCount += 1;
1335
+ admitContextTurn(conn);
1164
1336
  coordinator.noteLeaseAdmission();
1165
1337
  return conn;
1166
1338
  }
1167
1339
  export function acquireConnection(wsUrl, headers, context, _signal) {
1168
1340
  return reserveConnection(wsUrl, headers, context);
1169
1341
  }
1170
- export function ensureWarmConnection(wsUrl, headers) {
1342
+ export function ensureWarmConnection(wsUrl, headers, context = {}) {
1171
1343
  const coordinator = getTransportCoordinator();
1172
1344
  coordinator.notePrewarmAttempt();
1173
1345
  invalidateStaleAuthConnections(wsUrl, headers);
1174
- const key = contextScopeKey(wsUrl, headers, {});
1346
+ const key = contextScopeKey(wsUrl, headers, context);
1175
1347
  const now = Date.now();
1176
1348
  cleanupExpiredIdle(now);
1177
1349
  const existing = connectionPool.find((conn) => conn.scopeKey === key &&
@@ -1185,7 +1357,7 @@ export function ensureWarmConnection(wsUrl, headers) {
1185
1357
  existing.idleTimer = null;
1186
1358
  return existing;
1187
1359
  }
1188
- const accountFp = accountFingerprint(headers);
1360
+ const accountFp = accountFingerprint(headers, wsUrl);
1189
1361
  const provider = providerForUrl(wsUrl);
1190
1362
  const origin = canonicalWebSocketOrigin(wsUrl);
1191
1363
  const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "prewarm" });
@@ -1205,7 +1377,66 @@ export function ensureWarmConnection(wsUrl, headers) {
1205
1377
  coordinator.notePrewarmDeferred();
1206
1378
  return undefined;
1207
1379
  }
1208
- return create(wsUrl, headers, {}, true, started.permit);
1380
+ return create(wsUrl, headers, context, true, started.permit);
1381
+ }
1382
+ export function clearTransportContextState(wsUrl, headers, context) {
1383
+ const key = contextScopeKey(wsUrl, headers, context);
1384
+ clearContextAccounting(key);
1385
+ for (const conn of connectionPool) {
1386
+ if (conn.contextKey !== key)
1387
+ continue;
1388
+ conn.lastResponseID = undefined;
1389
+ conn.turnState = undefined;
1390
+ }
1391
+ }
1392
+ export function clearSessionTransportState(sessionID) {
1393
+ const keys = new Set();
1394
+ for (const key of turnStateByContext.keys()) {
1395
+ if (contextKeyMatchesSession(key, sessionID))
1396
+ keys.add(key);
1397
+ }
1398
+ for (const key of lastResponseIDByContext.keys()) {
1399
+ if (contextKeyMatchesSession(key, sessionID))
1400
+ keys.add(key);
1401
+ }
1402
+ for (const key of contextEpochByKey.keys()) {
1403
+ if (contextKeyMatchesSession(key, sessionID))
1404
+ keys.add(key);
1405
+ }
1406
+ for (const key of contextOwnerByKey.keys()) {
1407
+ if (contextKeyMatchesSession(key, sessionID))
1408
+ keys.add(key);
1409
+ }
1410
+ for (const key of scopeCooldowns.keys()) {
1411
+ if (contextKeyMatchesSession(key, sessionID))
1412
+ scopeCooldowns.delete(key);
1413
+ }
1414
+ for (const key of keys)
1415
+ clearContextAccounting(key);
1416
+ for (const conn of connectionPool) {
1417
+ if (!contextKeyMatchesSession(conn.contextKey, sessionID))
1418
+ continue;
1419
+ conn.lastResponseID = undefined;
1420
+ conn.turnState = undefined;
1421
+ }
1422
+ }
1423
+ export function clearAllTransportState() {
1424
+ const keys = new Set([
1425
+ ...turnStateByContext.keys(),
1426
+ ...lastResponseIDByContext.keys(),
1427
+ ...contextEpochByKey.keys(),
1428
+ ...contextOwnerByKey.keys(),
1429
+ ]);
1430
+ for (const key of keys)
1431
+ clearContextAccounting(key);
1432
+ turnStateByContext.clear();
1433
+ lastResponseIDByContext.clear();
1434
+ contextOwnerByKey.clear();
1435
+ // Keep bumped epochs so in-flight completions cannot republish under a recycled 0 epoch.
1436
+ for (const conn of connectionPool) {
1437
+ conn.lastResponseID = undefined;
1438
+ conn.turnState = undefined;
1439
+ }
1209
1440
  }
1210
1441
  export function closeConnections(predicate = () => true, message = "Session disposed") {
1211
1442
  for (const conn of [...connectionPool]) {
@@ -1251,7 +1482,10 @@ export function syncCoordinatorFromTransportConfig() {
1251
1482
  });
1252
1483
  }
1253
1484
  export function getTransportSnapshotForTesting() {
1254
- return getTransportCoordinator().snapshot();
1485
+ return {
1486
+ ...getTransportCoordinator().snapshot(),
1487
+ contextEntries: contextEntryCount(),
1488
+ };
1255
1489
  }
1256
1490
  export function resetPoolForTesting() {
1257
1491
  for (const conn of [...connectionPool]) {
@@ -1267,8 +1501,11 @@ export function resetPoolForTesting() {
1267
1501
  }
1268
1502
  turnStateByContext.clear();
1269
1503
  lastResponseIDByContext.clear();
1504
+ contextEpochByKey.clear();
1505
+ contextOwnerByKey.clear();
1270
1506
  scopeCooldowns.clear();
1271
1507
  authEndpoints.clear();
1508
+ contextEpochClock = 1;
1272
1509
  nextStreamID = 1;
1273
1510
  resetTransportCoordinatorForTesting();
1274
1511
  syncCoordinatorFromTransportConfig();