@openclaw/gateway-client 2026.9.4 → 2026.9.5

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.
@@ -225,6 +225,19 @@ function gatewayCredentialScope(gatewayUrl) {
225
225
  return normalizeGatewayScope(gatewayUrl, true);
226
226
  }
227
227
  //#endregion
228
+ //#region packages/gateway-client/src/model-catalog-connect.ts
229
+ const MODEL_CATALOG_SNAPSHOT = "model-catalog-snapshot";
230
+ /** A requested snapshot opts in only after the server advertises its connect field. */
231
+ function resolveModelCatalogConnect(params) {
232
+ const modelCatalog = params.serverCapabilities.includes(MODEL_CATALOG_SNAPSHOT) ? params.modelCatalog : void 0;
233
+ const caps = params.caps?.filter((cap) => cap !== MODEL_CATALOG_SNAPSHOT);
234
+ if (modelCatalog === void 0) return { caps };
235
+ return {
236
+ modelCatalog,
237
+ caps: [...caps ?? [], MODEL_CATALOG_SNAPSHOT]
238
+ };
239
+ }
240
+ //#endregion
228
241
  //#region packages/retry/src/index.ts
229
242
  const MAX_TIMER_TIMEOUT_MS = 2147e6;
230
243
  function computeBackoff(policy, attempt) {
@@ -306,9 +319,16 @@ const DEFAULT_RETRY_CONFIG = {
306
319
  maxDelayMs: 3e4,
307
320
  jitter: 0
308
321
  };
309
- const defaultSleep = (ms) => new Promise((resolve) => {
310
- setTimeout(resolve, ms);
311
- });
322
+ const defaultSleep = async (ms) => {
323
+ let remainingMs = ms;
324
+ do {
325
+ const delayMs = Math.min(remainingMs, MAX_TIMER_TIMEOUT_MS);
326
+ await new Promise((resolve) => {
327
+ setTimeout(resolve, delayMs);
328
+ });
329
+ remainingMs -= delayMs;
330
+ } while (remainingMs > 0);
331
+ };
312
332
  function clampNumber(value, fallback, min, max) {
313
333
  const next = Number.isFinite(value) ? value : void 0;
314
334
  if (next === void 0) return fallback;
@@ -630,8 +650,10 @@ var GatewayProtocolClient = class {
630
650
  this.listeners = new GatewayEventListeners();
631
651
  this.stopped = true;
632
652
  this.generation = 0;
653
+ this.connectionAbort = null;
633
654
  this.lastSeq = null;
634
655
  this.connectNonce = null;
656
+ this.serverCapabilities = [];
635
657
  this.connectSent = false;
636
658
  this.connectRequestSent = false;
637
659
  this.handshakeTimer = null;
@@ -676,6 +698,7 @@ var GatewayProtocolClient = class {
676
698
  }
677
699
  stop() {
678
700
  this.stopped = true;
701
+ this.connectionAbort?.abort();
679
702
  this.clearHandshakeTimer();
680
703
  this.reconnectSignal = null;
681
704
  this.reconnectSupervisor.reset();
@@ -700,6 +723,7 @@ var GatewayProtocolClient = class {
700
723
  return this.listeners.add(listener);
701
724
  }
702
725
  closeSocket(code, reason) {
726
+ this.connectionAbort?.abort();
703
727
  this.socket?.close(code, reason);
704
728
  }
705
729
  resetReconnectBackoff(initialMs) {
@@ -731,6 +755,7 @@ var GatewayProtocolClient = class {
731
755
  this.lastSeq = null;
732
756
  this.connectNonce = null;
733
757
  this.connectChallengeTs = void 0;
758
+ this.serverCapabilities = [];
734
759
  this.connectSent = this.connectRequestSent = false;
735
760
  this.socketOpened = false;
736
761
  this.helloReceived = false;
@@ -756,6 +781,7 @@ var GatewayProtocolClient = class {
756
781
  return;
757
782
  }
758
783
  this.generation = generation;
784
+ this.connectionAbort = new AbortController();
759
785
  this.socket = socket;
760
786
  const now = this.nowMs();
761
787
  this.connectTiming = {
@@ -806,7 +832,9 @@ var GatewayProtocolClient = class {
806
832
  planOrPromise = this.opts.buildConnectPlan({
807
833
  nonce: this.connectNonce,
808
834
  challengeTs: this.connectChallengeTs,
809
- generation
835
+ serverCapabilities: this.serverCapabilities,
836
+ generation,
837
+ ...this.connectAuthority(socket, generation)
810
838
  });
811
839
  } catch (error) {
812
840
  this.handleConnectPlanError(socket, generation, error);
@@ -819,7 +847,7 @@ var GatewayProtocolClient = class {
819
847
  this.sendConnectPlan(socket, generation, planOrPromise);
820
848
  }
821
849
  handleConnectPlanError(socket, generation, error) {
822
- if (!this.isActive(socket, generation)) return;
850
+ if (!this.isConnectCurrent(socket, generation)) return;
823
851
  const normalized = error instanceof Error ? error : new Error(String(error));
824
852
  const outcome = this.opts.onConnectPlanError?.(normalized) ?? {
825
853
  closeCode: 1008,
@@ -830,8 +858,9 @@ var GatewayProtocolClient = class {
830
858
  socket.close(outcome.closeCode, outcome.closeReason);
831
859
  }
832
860
  sendConnectPlan(socket, generation, plan) {
833
- if (!this.isActive(socket, generation) || !socket.isOpen()) return;
861
+ if (!this.isConnectCurrent(socket, generation)) return;
834
862
  const context = {
863
+ ...this.connectAuthority(socket, generation),
835
864
  generation,
836
865
  nonce: this.connectNonce,
837
866
  challengeTs: this.connectChallengeTs,
@@ -841,27 +870,43 @@ var GatewayProtocolClient = class {
841
870
  this.recordTiming("request-sent", generation, plan);
842
871
  this.connectRequestSent = true;
843
872
  this.request("connect", this.opts.buildConnectParams(plan)).then((hello) => {
844
- if (!this.isActive(socket, generation) || !socket.isOpen()) return;
873
+ if (!this.isConnectCurrent(socket, generation)) return;
845
874
  this.helloReceived = true;
846
875
  this.clearHandshakeTimer();
847
876
  this.connectFailure = void 0;
848
877
  this.reconnectSupervisor.reset();
849
878
  this.recordTiming("hello", generation, plan);
850
- this.opts.onConnectHello?.(hello, context);
851
- this.invoke("hello", () => this.opts.onHello?.(hello));
879
+ const publishHello = () => {
880
+ if (!this.isConnectCurrent(socket, generation)) return;
881
+ this.invoke("hello", () => this.opts.onHello?.(hello));
882
+ };
883
+ const accepted = this.opts.onConnectHello?.(hello, context);
884
+ if (accepted instanceof Promise) return accepted.then(publishHello);
885
+ return publishHello();
852
886
  }).catch((error) => {
853
887
  if (!this.isActive(socket, generation)) return;
854
888
  const requestError = error instanceof GatewayProtocolRequestError ? error : new GatewayProtocolRequestError({ message: String(error) });
889
+ this.connectFailure = { error: requestError };
855
890
  const outcome = this.opts.onConnectFailure?.(requestError, context) ?? {
856
891
  closeCode: 1008,
857
892
  closeReason: "connect failed"
858
893
  };
859
- this.connectFailure = {
860
- error: requestError,
861
- reconnectDelayMs: outcome.reconnectDelayMs
894
+ const applyFailure = (decision) => {
895
+ if (!this.isActive(socket, generation)) return;
896
+ this.connectFailure = {
897
+ error: requestError,
898
+ reconnectDelayMs: decision.reconnectDelayMs
899
+ };
900
+ if (decision.stop) this.stopped = true;
901
+ this.connectionAbort?.abort();
902
+ socket.close(decision.closeCode, decision.closeReason);
862
903
  };
863
- if (outcome.stop) this.stopped = true;
864
- socket.close(outcome.closeCode, outcome.closeReason);
904
+ if (outcome instanceof Promise) return outcome.then(applyFailure);
905
+ return applyFailure(outcome);
906
+ }).catch((error) => {
907
+ if (!this.isConnectCurrent(socket, generation)) return;
908
+ this.opts.onConnectError?.(error instanceof Error ? error : new Error(String(error)));
909
+ this.closeSocket(1008, "connect failed");
865
910
  });
866
911
  }
867
912
  handleMessage(socket, generation, raw) {
@@ -887,6 +932,7 @@ var GatewayProtocolClient = class {
887
932
  return;
888
933
  }
889
934
  this.connectNonce = nonce;
935
+ this.serverCapabilities = Array.isArray(payload?.capabilities) ? payload.capabilities.filter((value) => typeof value === "string") : [];
890
936
  const challengeTs = payload?.ts;
891
937
  this.connectChallengeTs = typeof challengeTs === "number" && Number.isSafeInteger(challengeTs) && challengeTs >= 0 ? challengeTs : null;
892
938
  this.recordTiming("challenge", generation);
@@ -934,6 +980,7 @@ var GatewayProtocolClient = class {
934
980
  return;
935
981
  }
936
982
  this.socket = null;
983
+ this.connectionAbort?.abort();
937
984
  this.clearHandshakeTimer();
938
985
  const context = {
939
986
  ...this.closeContext(),
@@ -978,6 +1025,20 @@ var GatewayProtocolClient = class {
978
1025
  connectFailure: this.connectFailure
979
1026
  };
980
1027
  }
1028
+ isConnectCurrent(socket, generation) {
1029
+ return this.isActive(socket, generation) && socket.isOpen() && !this.connectionAbort?.signal.aborted;
1030
+ }
1031
+ connectAuthority(socket, generation) {
1032
+ const signal = this.connectionAbort?.signal;
1033
+ if (!signal) throw new Error("gateway connection authority is unavailable");
1034
+ return {
1035
+ signal,
1036
+ assertCurrent: () => {
1037
+ signal.throwIfAborted();
1038
+ if (!this.isConnectCurrent(socket, generation)) throw new Error("gateway connection retired");
1039
+ }
1040
+ };
1041
+ }
981
1042
  isActive(socket, generation) {
982
1043
  return !this.stopped && this.socket === socket && this.generation === generation;
983
1044
  }
@@ -1069,14 +1130,15 @@ function stringifyObjectValue(value, stack, normalizeString) {
1069
1130
  }
1070
1131
  const record = value;
1071
1132
  if (normalizeString === preserveString) {
1072
- const fields = [];
1073
- for (const key of Object.keys(record).toSorted()) fields.push(`${JSON.stringify(key)}:${stringifyStableValue(record[key], stack, normalizeString)}`);
1133
+ const fields = Object.keys(record).sort();
1134
+ let fieldIndex = 0;
1135
+ for (const key of fields) fields[fieldIndex++] = `${JSON.stringify(key)}:${stringifyStableValue(record[key], stack, normalizeString)}`;
1074
1136
  return `{${fields.join(",")}}`;
1075
1137
  }
1076
1138
  const entries = Object.keys(record).map((key) => ({
1077
1139
  key,
1078
1140
  normalizedKey: normalizeString(key)
1079
- })).toSorted((left, right) => {
1141
+ })).sort((left, right) => {
1080
1142
  return compareStableStrings(left.normalizedKey, right.normalizedKey) || compareStableStrings(left.key, right.key);
1081
1143
  });
1082
1144
  const serializedFields = [];
@@ -1124,11 +1186,11 @@ function readSessionMessageIdentity(message, envelope) {
1124
1186
  const persistedRunId = normalizeSessionProjectionRunId(idempotencyKey);
1125
1187
  const envelopeRunId = normalizeSessionProjectionRunId(envelope?.runId);
1126
1188
  const metadataRunId = normalizeSessionProjectionRunId(metadata?.runId);
1189
+ const fallbackRunId = normalizeSessionProjectionRunId(asNullableRecord(record.openclawStreamFallback)?.runId);
1127
1190
  const mirroredMessage = readSessionProjectionString(metadata?.mirrorOrigin) !== null;
1128
1191
  const isCliAssistant = role === "assistant" && readSessionProjectionString(record.api)?.toLowerCase() === "cli";
1129
1192
  const canonicalPersistedRunId = isCliAssistant && persistedRunId?.startsWith("cli-assistant:") ? readSessionProjectionString(persistedRunId.slice(14)) : persistedRunId;
1130
- const optimisticRunId = metadata && Object.keys(metadata).every((key) => key === "idempotencyKey") ? canonicalPersistedRunId : null;
1131
- const runId = role === "assistant" ? metadataRunId ?? envelopeRunId ?? (isCliAssistant || !mirroredMessage ? canonicalPersistedRunId : null) ?? optimisticRunId : metadataRunId ?? canonicalPersistedRunId ?? envelopeRunId;
1193
+ const runId = role === "assistant" ? metadataRunId ?? envelopeRunId ?? fallbackRunId ?? (isCliAssistant || !mirroredMessage ? canonicalPersistedRunId : null) : metadataRunId ?? canonicalPersistedRunId ?? envelopeRunId;
1132
1194
  return {
1133
1195
  role,
1134
1196
  id: readSessionProjectionString(metadata?.id) ?? readSessionProjectionString(envelope?.messageId),
@@ -1150,11 +1212,51 @@ function readAssistantStreamSegmentIdentity(message) {
1150
1212
  if (readSessionProjectionString(record?.role)?.toLowerCase() !== "assistant") return;
1151
1213
  const fallback = asNullableRecord(record?.openclawStreamFallback);
1152
1214
  const itemId = readSessionProjectionString(fallback?.itemId);
1215
+ if (!itemId) return;
1153
1216
  const runId = readSessionMessageIdentity(message)?.runId ?? readSessionProjectionString(record?.runId) ?? readSessionProjectionString(fallback?.runId);
1154
- return itemId ? {
1217
+ return {
1155
1218
  itemId,
1156
1219
  ...runId ? { runId } : {}
1157
- } : void 0;
1220
+ };
1221
+ }
1222
+ /** A saved occurrence can enrich its live projection, never another durable row. */
1223
+ function sameAssistantPersistenceReceipt(left, right) {
1224
+ return Boolean(left?.role === "assistant" && right?.role === "assistant" && !left.isImported && !right.isImported && left.idempotencyKey && left.idempotencyKey === right.idempotencyKey && (!left.id && left.sequence === null || !right.id && right.sequence === null));
1225
+ }
1226
+ /** Local turns have no durable transcript metadata beyond their own optional send key. */
1227
+ function isLocallyOptimisticSessionMessage(message) {
1228
+ const record = asNullableRecord(message);
1229
+ const role = readSessionProjectionString(record?.role)?.toLowerCase();
1230
+ if (role !== "user" && role !== "assistant") return false;
1231
+ if (asNullableRecord(record?.openclawStreamFallback)) return false;
1232
+ const metadata = asNullableRecord(record?.["__openclaw"]);
1233
+ return !metadata || Object.keys(metadata).every((key) => key === "idempotencyKey");
1234
+ }
1235
+ function sameTranscriptIdentity(left, right) {
1236
+ if (!left || !right || left.role !== right.role) return false;
1237
+ if (left.isImported || right.isImported) {
1238
+ if (!left.isImported || !right.isImported) return false;
1239
+ if (left.externalSource || right.externalSource) return Boolean(left.externalSource && left.externalSource === right.externalSource);
1240
+ return left.sequence !== null && right.sequence !== null && left.sequence === right.sequence;
1241
+ }
1242
+ if (left.id || right.id) return Boolean(left.id && right.id && left.id === right.id);
1243
+ return left.sequence !== null && right.sequence !== null && left.sequence === right.sequence;
1244
+ }
1245
+ /** Normalize a message into its live, durable, or pending projection entry. */
1246
+ function createSessionProjectionEntry(message, options) {
1247
+ const identity = readSessionMessageIdentity(message, options?.envelope);
1248
+ const fallback = asNullableRecord(asNullableRecord(message)?.openclawStreamFallback);
1249
+ const provisionalFallback = Boolean(fallback && identity?.role === "assistant" && !identity.id && identity.sequence === null);
1250
+ const inferredPendingRunId = options?.live !== true && isLocallyOptimisticSessionMessage(message) ? identity?.runId : null;
1251
+ const pendingRunId = normalizeSessionProjectionRunId(options?.pendingRunId ?? inferredPendingRunId);
1252
+ return {
1253
+ message,
1254
+ identity,
1255
+ afterSequence: options?.envelope?.afterSequence !== void 0 ? options.envelope.afterSequence : provisionalFallback && typeof fallback?.afterSequence === "number" ? fallback.afterSequence : void 0,
1256
+ live: options?.live === true || provisionalFallback,
1257
+ pending: pendingRunId !== null,
1258
+ pendingRunId
1259
+ };
1158
1260
  }
1159
1261
  //#endregion
1160
1262
  //#region packages/gateway-client/src/session-projection-message-content.ts
@@ -1199,11 +1301,30 @@ function isSessionProjectionErrorMessage(message, errorMessage) {
1199
1301
  //#endregion
1200
1302
  //#region packages/gateway-client/src/session-projection-final-identity.ts
1201
1303
  /** Terminal identity rules used to reconcile live and durable assistant projections. */
1304
+ /**
1305
+ * The class of rows the #148297 relaxation newly admits as finals: persisted
1306
+ * with the run's terminal tool stop reason while carrying no tool-call
1307
+ * content. Both adoption directions and every position rule scope to this
1308
+ * predicate, so pre-existing match semantics stay untouched.
1309
+ */
1310
+ function isToolUsePersistedFinalRow(message) {
1311
+ return asNullableRecord(message)?.["stopReason"] === "toolUse" && !isSessionProjectionToolContinuation(message);
1312
+ }
1313
+ /** Tool-bearing assistant rows are continuations even without a tool stop reason. */
1314
+ function isSessionProjectionToolContinuation(message) {
1315
+ const record = asNullableRecord(message);
1316
+ if (Array.isArray(record?.content) && record.content.some((block) => {
1317
+ const type = asNullableRecord(block)?.type;
1318
+ return type === "toolCall" || type === "toolUse" || type === "functionCall";
1319
+ })) return true;
1320
+ return record?.stopReason === "toolUse" && !hasDisplayableSessionMessage(message);
1321
+ }
1202
1322
  function readPersistedFinalIdentity(message) {
1203
1323
  const identity = readSessionMessageIdentity(message);
1204
1324
  if (identity?.externalSource) return `import:${identity.role}:${identity.externalSource}`;
1205
1325
  if (identity?.id && !identity.isImported) return `id:${identity.role}:${identity.id}`;
1206
1326
  if (identity?.sequence !== null && identity?.sequence !== void 0) return `seq:${identity.role}:${identity.sequence}`;
1327
+ if (identity?.role === "assistant" && !identity.isImported && identity.idempotencyKey) return `key:assistant:${identity.idempotencyKey}`;
1207
1328
  return null;
1208
1329
  }
1209
1330
  function hasCompatiblePersistedFinalIdentity(currentMessage, incomingMessage) {
@@ -1215,6 +1336,7 @@ function hasCompatiblePersistedFinalIdentity(currentMessage, incomingMessage) {
1215
1336
  if (current.externalSource && incoming.externalSource) return current.externalSource === incoming.externalSource;
1216
1337
  return current.sequence !== null && incoming.sequence !== null && current.sequence === incoming.sequence;
1217
1338
  }
1339
+ if (sameAssistantPersistenceReceipt(current, incoming)) return true;
1218
1340
  if (current.id && incoming.id) return current.id === incoming.id;
1219
1341
  return current.sequence !== null && incoming.sequence !== null && current.sequence === incoming.sequence;
1220
1342
  }
@@ -1244,14 +1366,6 @@ function hasTerminalStopReason(message) {
1244
1366
  const stopReason = asNullableRecord(message)?.stopReason;
1245
1367
  return stopReason === "stop" || stopReason === "length" || stopReason === "error" || stopReason === "aborted" || stopReason === "end_turn";
1246
1368
  }
1247
- function hasCompletedRunSnapshotContext(entry, snapshot, runId) {
1248
- if (!runId || entry.identity?.runId !== runId) return false;
1249
- const entryIndex = snapshot.indexOf(entry);
1250
- if (entryIndex < 0) return false;
1251
- const hasEarlierUser = snapshot.slice(0, entryIndex).some((candidate) => candidate.identity?.role === "user" && candidate.identity.runId === runId);
1252
- const hasLaterAssistant = snapshot.slice(entryIndex + 1).some((candidate) => candidate.identity?.role === "assistant" && candidate.identity.runId === runId);
1253
- return hasEarlierUser && !hasLaterAssistant;
1254
- }
1255
1369
  /** Read stable persisted identity first, falling back to canonical display content. */
1256
1370
  function readSessionProjectionFinalMessageIdentity(message) {
1257
1371
  if (!hasDisplayableSessionMessage(message)) return null;
@@ -1269,11 +1383,41 @@ function hasSessionProjectionAcceptedFinal(run, message) {
1269
1383
  }
1270
1384
  /** Match an unsequenced live terminal to exactly one durable same-run terminal row. */
1271
1385
  function findUniqueSnapshotTerminalMatch(current, matches, run, snapshot) {
1272
- if (!current.live || current.identity?.role !== "assistant" || current.identity.id || current.identity.sequence !== null || !run || run.status === "streaming") return null;
1386
+ if (!current.live || current.identity?.role !== "assistant" || current.identity.id || current.identity.sequence !== null || !run || run.status === "streaming" || matches.length === 0) return null;
1273
1387
  const terminalContent = readFinalContentIdentity(current.message);
1274
1388
  if (!terminalContent || readFinalContentIdentity(run.message) !== terminalContent) return null;
1389
+ let snapshotIndexes;
1390
+ let firstUserIndex = -1;
1391
+ let lastAssistantIndex = -1;
1392
+ const ensureRunIndexes = () => {
1393
+ if (snapshotIndexes) return;
1394
+ const runId = current.identity?.runId;
1395
+ const indexes = /* @__PURE__ */ new Map();
1396
+ if (runId) snapshot.forEach((candidate, index) => {
1397
+ if (candidate.identity?.runId === runId) {
1398
+ if (!indexes.has(candidate)) indexes.set(candidate, index);
1399
+ if (candidate.identity.role === "user" && firstUserIndex < 0) firstUserIndex = index;
1400
+ else if (candidate.identity.role === "assistant") lastAssistantIndex = index;
1401
+ }
1402
+ });
1403
+ snapshotIndexes = indexes;
1404
+ };
1405
+ const hasCompletedRunSnapshotContext = (entry) => {
1406
+ const runId = current.identity?.runId;
1407
+ if (!runId || entry.identity?.runId !== runId) return false;
1408
+ ensureRunIndexes();
1409
+ const entryIndex = snapshotIndexes?.get(entry);
1410
+ return entryIndex !== void 0 && firstUserIndex >= 0 && firstUserIndex < entryIndex && lastAssistantIndex <= entryIndex;
1411
+ };
1412
+ const isLastSameRunAssistantRow = (entry) => {
1413
+ const runId = current.identity?.runId;
1414
+ if (!runId || entry.identity?.runId !== runId) return false;
1415
+ ensureRunIndexes();
1416
+ const entryIndex = snapshotIndexes?.get(entry);
1417
+ return entryIndex !== void 0 && lastAssistantIndex >= 0 && entryIndex >= lastAssistantIndex;
1418
+ };
1275
1419
  const durableTerminalMatches = matches.filter((entry) => {
1276
- return (asNullableRecord(asNullableRecord(entry.message)?.["__openclaw"])?.runTerminal === true || entry.identity?.runId === current.identity?.runId && hasTerminalStopReason(entry.message) || hasCompletedRunSnapshotContext(entry, snapshot, current.identity?.runId ?? null)) && readFinalContentIdentity(entry.message) === terminalContent;
1420
+ return (asNullableRecord(asNullableRecord(entry.message)?.["__openclaw"])?.runTerminal === true || entry.identity?.runId === current.identity?.runId && (hasTerminalStopReason(entry.message) || asNullableRecord(entry.message)?.["stopReason"] === "toolUse" && !isSessionProjectionToolContinuation(entry.message) && isLastSameRunAssistantRow(entry)) || hasCompletedRunSnapshotContext(entry)) && readFinalContentIdentity(entry.message) === terminalContent;
1277
1421
  });
1278
1422
  const entry = durableTerminalMatches.length === 1 ? durableTerminalMatches[0] : void 0;
1279
1423
  if (!entry) return null;
@@ -1352,35 +1496,15 @@ const SESSION_PROJECTION_SCOPE_KEYS = [
1352
1496
  "lifecycleRevision",
1353
1497
  "activeLeafEntryId"
1354
1498
  ];
1355
- /** Local turns have no durable transcript metadata beyond their own optional send key. */
1356
- function isLocallyOptimisticSessionMessage(message) {
1357
- const identity = readSessionMessageIdentity(message);
1358
- if (!identity || identity.role !== "user" && identity.role !== "assistant") return false;
1359
- const metadata = asNullableRecord(asNullableRecord(message)?.["__openclaw"]);
1360
- return !metadata || Object.keys(metadata).every((key) => key === "idempotencyKey");
1361
- }
1362
- function createEntry(message, options) {
1363
- const identity = readSessionMessageIdentity(message, options?.envelope);
1364
- const inferredPendingRunId = options?.live !== true && isLocallyOptimisticSessionMessage(message) ? identity?.runId : null;
1365
- const pendingRunId = normalizeSessionProjectionRunId(options?.pendingRunId ?? inferredPendingRunId);
1366
- return {
1367
- message,
1368
- identity,
1369
- afterSequence: options?.envelope?.afterSequence,
1370
- live: options?.live === true,
1371
- pending: pendingRunId !== null,
1372
- pendingRunId
1373
- };
1374
- }
1375
1499
  function createProjectionEntries(messages) {
1376
1500
  let pendingUserRunId = null;
1377
1501
  return messages.map((message) => {
1378
- const entry = createEntry(message);
1502
+ const entry = createSessionProjectionEntry(message);
1379
1503
  if (entry.identity?.role === "user") {
1380
1504
  pendingUserRunId = entry.pending ? entry.pendingRunId : null;
1381
1505
  return entry;
1382
1506
  }
1383
- if (pendingUserRunId && entry.identity?.role === "assistant" && !entry.pending && isLocallyOptimisticSessionMessage(message)) return createEntry(message, { pendingRunId: pendingUserRunId });
1507
+ if (pendingUserRunId && entry.identity?.role === "assistant" && !entry.pending && isLocallyOptimisticSessionMessage(message)) return createSessionProjectionEntry(message, { pendingRunId: pendingUserRunId });
1384
1508
  if (!isLocallyOptimisticSessionMessage(message)) pendingUserRunId = null;
1385
1509
  return entry;
1386
1510
  });
@@ -1403,26 +1527,21 @@ function readEventScope(event) {
1403
1527
  for (const key of SESSION_PROJECTION_SCOPE_KEYS) if (event[key] !== void 0) Object.assign(scope, { [key]: event[key] });
1404
1528
  return scope;
1405
1529
  }
1406
- function sameTranscriptIdentity(left, right) {
1407
- if (!left || !right || left.role !== right.role) return false;
1408
- if (left.isImported || right.isImported) {
1409
- if (!left.isImported || !right.isImported) return false;
1410
- if (left.externalSource || right.externalSource) return Boolean(left.externalSource && left.externalSource === right.externalSource);
1411
- return left.sequence !== null && right.sequence !== null && left.sequence === right.sequence;
1412
- }
1413
- if (left.id || right.id) return Boolean(left.id && right.id && left.id === right.id);
1414
- return left.sequence !== null && right.sequence !== null && left.sequence === right.sequence;
1415
- }
1416
1530
  function entryMatches(left, right, allowSnapshotPromotion = false) {
1531
+ const leftSegment = readAssistantStreamSegmentIdentity(left.message);
1532
+ const rightSegment = readAssistantStreamSegmentIdentity(right.message);
1533
+ if (leftSegment?.itemId !== rightSegment?.itemId) return false;
1417
1534
  if (sameTranscriptIdentity(left.identity, right.identity)) return true;
1535
+ if (sameAssistantPersistenceReceipt(left.identity, right.identity)) return true;
1536
+ if (left.identity?.role === "assistant" && right.identity?.role === "assistant" && left.identity.idempotencyKey && right.identity.idempotencyKey && left.identity.idempotencyKey !== right.identity.idempotencyKey) return false;
1418
1537
  const durableEntry = left.identity?.id ? left : right.identity?.id ? right : null;
1419
1538
  const provisionalEntry = durableEntry === left ? right : durableEntry === right ? left : null;
1420
1539
  const durableMetadata = asNullableRecord(asNullableRecord(durableEntry?.message)?.["__openclaw"]);
1421
1540
  if (durableEntry?.identity?.role === "assistant" && provisionalEntry?.identity?.role === "assistant" && !durableEntry.identity.isImported && !provisionalEntry.identity.isImported && !provisionalEntry.identity.id) {
1422
- const durableSegment = readAssistantStreamSegmentIdentity(durableEntry.message);
1423
- const provisionalSegment = readAssistantStreamSegmentIdentity(provisionalEntry.message);
1541
+ const durableSegment = durableEntry === left ? leftSegment : rightSegment;
1542
+ const provisionalSegment = durableEntry === left ? rightSegment : leftSegment;
1424
1543
  if (provisionalEntry.identity.sequence === null && durableSegment?.runId && durableSegment.runId === provisionalSegment?.runId && durableSegment.itemId === provisionalSegment.itemId) return true;
1425
- if (provisionalEntry.live && provisionalEntry.identity.sequence === null && (provisionalEntry.afterSequence === void 0 || provisionalEntry.afterSequence !== null && durableEntry.identity.sequence !== null && durableEntry.identity.sequence > provisionalEntry.afterSequence) && durableEntry.identity.runId && durableEntry.identity.runId === provisionalEntry.identity.runId && (readSessionProjectionString(durableMetadata?.mirrorOrigin) === null || durableMetadata?.runTerminal === true)) return true;
1544
+ if (provisionalEntry.live && !durableSegment && !isSessionProjectionToolContinuation(durableEntry.message) && (!isToolUsePersistedFinalRow(durableEntry.message) || readFinalContentIdentity(durableEntry.message) === readFinalContentIdentity(provisionalEntry.message)) && provisionalEntry.identity.sequence === null && (provisionalEntry.afterSequence === void 0 || provisionalEntry.afterSequence !== null && durableEntry.identity.sequence !== null && durableEntry.identity.sequence > provisionalEntry.afterSequence) && durableEntry.identity.runId && durableEntry.identity.runId === provisionalEntry.identity.runId && (readSessionProjectionString(durableMetadata?.mirrorOrigin) === null || durableMetadata?.runTerminal === true)) return true;
1426
1545
  }
1427
1546
  const persisted = left.identity;
1428
1547
  const observed = right.identity;
@@ -1470,28 +1589,54 @@ function insertEntry(entries, incoming, runs) {
1470
1589
  ...entries.slice(nextIndex)
1471
1590
  ];
1472
1591
  }
1473
- function projectLiveSessionMessage(state, message, envelope, scope = {}) {
1592
+ function projectLiveSessionMessage(initialState, message, envelope, scope = {}) {
1593
+ let state = initialState;
1474
1594
  if (!scopesMatch(state.scope, scope)) return state;
1475
- const incoming = createEntry(message, {
1595
+ const incoming = createSessionProjectionEntry(message, {
1476
1596
  envelope,
1477
1597
  live: true
1478
1598
  });
1479
1599
  if (!incoming.identity) return state;
1480
1600
  const matches = state.entries.filter((entry) => entryMatches(entry, incoming));
1481
1601
  const existing = matches.find((entry) => sameTranscriptIdentity(entry.identity, incoming.identity)) ?? (matches.length === 1 ? matches[0] : void 0);
1602
+ if (existing && !sameTranscriptIdentity(existing.identity, incoming.identity) && !sameAssistantPersistenceReceipt(existing.identity, incoming.identity)) {
1603
+ const durable = existing.identity?.id ? existing : incoming.identity.id ? incoming : null;
1604
+ const provisional = durable === existing ? incoming : existing;
1605
+ if (durable && isToolUsePersistedFinalRow(durable.message)) {
1606
+ const history = state.entries.filter((entry) => entry !== provisional);
1607
+ const snapshot = durable === incoming ? insertEntry(history, durable) : history;
1608
+ const runId = provisional.identity?.runId;
1609
+ const run = runId ? state.runs[runId] : void 0;
1610
+ const terminalMatch = findUniqueSnapshotTerminalMatch(provisional, [durable], run, snapshot);
1611
+ if (!terminalMatch) return withEntries(state, insertEntry(state.entries, incoming, state.runs));
1612
+ if (terminalMatch.inferred && durable.identity && runId && run) {
1613
+ const inferredSnapshotTerminal = {
1614
+ entry: provisional,
1615
+ matchedIdentity: durable.identity
1616
+ };
1617
+ state = {
1618
+ ...state,
1619
+ runs: {
1620
+ ...state.runs,
1621
+ [runId]: {
1622
+ ...run,
1623
+ inferredSnapshotTerminal
1624
+ }
1625
+ }
1626
+ };
1627
+ }
1628
+ }
1629
+ }
1482
1630
  if (!existing) return withEntries(state, insertEntry(state.entries, incoming, state.runs));
1483
1631
  const existingIndex = state.entries.indexOf(existing);
1484
1632
  if (existing.message === message && existing.live && !existing.pending) return state;
1485
1633
  if (!existing.pending && existing.identity?.id && !incoming.identity.id) return state;
1486
1634
  if (incoming.identity.sequence !== null && (existing.pending || existing.identity?.sequence === null)) {
1487
1635
  const sequence = incoming.identity.sequence;
1488
- return withEntries(state, state.entries.some(({ identity }, index) => identity?.sequence != null && (index < existingIndex ? identity.sequence > sequence : identity.sequence < sequence)) ? insertEntry(state.entries.filter((_, index) => index !== existingIndex), incoming, state.runs) : state.entries.toSpliced(existingIndex, 1, incoming));
1636
+ const violatesOrder = state.entries.some(({ identity }, index) => identity?.sequence != null && (index < existingIndex ? identity.sequence > sequence : identity.sequence < sequence));
1637
+ return withEntries(state, violatesOrder ? insertEntry(state.entries.filter((_, index) => index !== existingIndex), incoming, state.runs) : state.entries.toSpliced(existingIndex, 1, incoming));
1489
1638
  }
1490
- return withEntries(state, [
1491
- ...state.entries.slice(0, existingIndex),
1492
- incoming,
1493
- ...state.entries.slice(existingIndex + 1)
1494
- ]);
1639
+ return withEntries(state, state.entries.toSpliced(existingIndex, 1, incoming));
1495
1640
  }
1496
1641
  /** Only observed live events and this client's pending turns may survive an older snapshot. */
1497
1642
  function reconcileSessionProjectionSnapshot(state, messages, scope = {}, options = {}) {
@@ -1502,9 +1647,10 @@ function reconcileSessionProjectionSnapshot(state, messages, scope = {}, options
1502
1647
  for (const current of state.entries) {
1503
1648
  if (!current.live && !current.pending || options.shouldIncludeMessage?.(current.message) === false) continue;
1504
1649
  const matches = entries.filter((entry) => entryMatches(entry, current, true));
1650
+ const uniqueMatch = matches.length === 1 ? matches[0] : void 0;
1505
1651
  const run = current.identity?.runId ? runs[current.identity.runId] : void 0;
1506
1652
  const terminalMatch = findUniqueSnapshotTerminalMatch(current, matches, run, entries);
1507
- if (matches.length === 1 && !isUnsequencedLiveTerminal(current, run) || terminalMatch) {
1653
+ if (uniqueMatch && (sameAssistantPersistenceReceipt(uniqueMatch.identity, current.identity) || !isUnsequencedLiveTerminal(current, run)) || terminalMatch) {
1508
1654
  if (terminalMatch?.inferred && terminalMatch.entry.identity && current.identity?.runId && run) runs[current.identity.runId] = {
1509
1655
  ...run,
1510
1656
  inferredSnapshotTerminal: {
@@ -1614,7 +1760,7 @@ function reduceSessionProjection(state, event) {
1614
1760
  case "messagePersisted": return projectLiveSessionMessage(state, event.message, event.envelope ?? event, scope);
1615
1761
  case "sendPending": {
1616
1762
  const pendingRunId = normalizeSessionProjectionRunId(event.idempotencyKey ?? event.runId);
1617
- const incoming = createEntry(event.message, { pendingRunId });
1763
+ const incoming = createSessionProjectionEntry(event.message, { pendingRunId });
1618
1764
  if (!pendingRunId || !incoming.identity) return state;
1619
1765
  const seed = state.entries.find((entry) => entry.message === event.message);
1620
1766
  if (seed && !seed.pending && incoming.identity.id === null && !incoming.identity.isImported && incoming.identity.runId === pendingRunId) return withEntries(state, state.entries.map((entry) => entry === seed ? {
@@ -1883,4 +2029,4 @@ function releaseGatewaySessionMessageSubscription(subscription) {
1883
2029
  return sessionMessageSubscriptionOwners.get(subscription)?.coordinator.release(subscription) ?? Promise.resolve();
1884
2030
  }
1885
2031
  //#endregion
1886
- export { shouldRetryGatewayWithDeviceToken as A, isGatewayProtocolResponseError as C, buildGatewayConnectAuth as D, GatewayBrowserDeviceAuthLifecycle as E, buildDeviceAuthPayloadV3 as M, normalizeDeviceMetadataForAuth as N, resolveGatewayConnectScopes as O, GatewayProtocolRequestTimeoutError as S, gatewayOriginScope as T, readSessionMessageSequence as _, createSessionProjection as a, GatewayProtocolClient as b, reconcileSessionProjectionSnapshot as c, hasSessionProjectionAcceptedFinal as d, readSessionProjectionFinalMessageIdentity as f, readSessionMessageIdentity as g, readAssistantStreamSegmentIdentity as h, resetGatewaySessionMessageSubscriptionCoordinator as i, buildDeviceAuthPayload as j, selectGatewayConnectAuth as k, reduceSessionProjection as l, normalizeSessionProjectionRunId as m, getGatewaySessionMessageSubscriptionCoordinator as n, isLocallyOptimisticSessionMessage as o, isSessionProjectionErrorMessage as p, releaseGatewaySessionMessageSubscription as r, projectLiveSessionMessage as s, GatewaySessionMessageSubscriptionCoordinator as t, reduceSessionProjectionRunEvent as u, isRecord as v, gatewayCredentialScope as w, GatewayProtocolRequestError as x, shouldPauseGatewayReconnect as y };
2032
+ export { selectGatewayConnectAuth as A, isGatewayProtocolResponseError as C, GatewayBrowserDeviceAuthLifecycle as D, gatewayOriginScope as E, buildDeviceAuthPayload as M, buildDeviceAuthPayloadV3 as N, buildGatewayConnectAuth as O, normalizeDeviceMetadataForAuth as P, GatewayProtocolRequestTimeoutError as S, gatewayCredentialScope as T, readSessionMessageSequence as _, createSessionProjection as a, GatewayProtocolClient as b, reduceSessionProjection as c, readSessionProjectionFinalMessageIdentity as d, isSessionProjectionErrorMessage as f, readSessionMessageIdentity as g, readAssistantStreamSegmentIdentity as h, resetGatewaySessionMessageSubscriptionCoordinator as i, shouldRetryGatewayWithDeviceToken as j, resolveGatewayConnectScopes as k, reduceSessionProjectionRunEvent as l, normalizeSessionProjectionRunId as m, getGatewaySessionMessageSubscriptionCoordinator as n, projectLiveSessionMessage as o, isLocallyOptimisticSessionMessage as p, releaseGatewaySessionMessageSubscription as r, reconcileSessionProjectionSnapshot as s, GatewaySessionMessageSubscriptionCoordinator as t, hasSessionProjectionAcceptedFinal as u, isRecord as v, resolveModelCatalogConnect as w, GatewayProtocolRequestError as x, shouldPauseGatewayReconnect as y };
@@ -56,6 +56,16 @@ declare function readAssistantStreamSegmentIdentity(message: unknown): {
56
56
  itemId: string;
57
57
  runId?: string;
58
58
  } | undefined;
59
+ /** Local turns have no durable transcript metadata beyond their own optional send key. */
60
+ declare function isLocallyOptimisticSessionMessage(message: unknown): boolean;
61
+ type SessionProjectionEntry = {
62
+ message: unknown;
63
+ identity: SessionMessageIdentity | null;
64
+ afterSequence?: number | null;
65
+ live: boolean;
66
+ pending: boolean;
67
+ pendingRunId: string | null;
68
+ };
59
69
  //#endregion
60
70
  //#region packages/gateway-client/src/session-projection-final-identity.d.ts
61
71
  type TerminalProjectionRun = {
@@ -112,14 +122,6 @@ type SessionProjectionRun = {
112
122
  errorKind?: string;
113
123
  errorMessage?: string;
114
124
  };
115
- type SessionProjectionEntry = {
116
- message: unknown;
117
- identity: SessionMessageIdentity | null;
118
- afterSequence?: number | null;
119
- live: boolean;
120
- pending: boolean;
121
- pendingRunId: string | null;
122
- };
123
125
  type SessionProjectionState = {
124
126
  scope: SessionProjectionScope;
125
127
  entries: readonly SessionProjectionEntry[];
@@ -166,10 +168,8 @@ type SessionProjectionEvent = ScopedSessionProjectionEvent & ({
166
168
  } | {
167
169
  type: "reconnected";
168
170
  });
169
- /** Local turns have no durable transcript metadata beyond their own optional send key. */
170
- declare function isLocallyOptimisticSessionMessage(message: unknown): boolean;
171
171
  declare function createSessionProjection(scope?: SessionProjectionScope, messages?: readonly unknown[]): SessionProjectionState;
172
- declare function projectLiveSessionMessage(state: SessionProjectionState, message: unknown, envelope?: SessionMessageEnvelope, scope?: SessionProjectionScope): SessionProjectionState;
172
+ declare function projectLiveSessionMessage(initialState: SessionProjectionState, message: unknown, envelope?: SessionMessageEnvelope, scope?: SessionProjectionScope): SessionProjectionState;
173
173
  /** Only observed live events and this client's pending turns may survive an older snapshot. */
174
174
  declare function reconcileSessionProjectionSnapshot(state: SessionProjectionState, messages: readonly unknown[], scope?: SessionProjectionScope, options?: SessionProjectionSnapshotOptions): SessionProjectionState;
175
175
  /** Reduces durable events, snapshots, and transport lifecycle without client-specific policy. */
@@ -209,4 +209,4 @@ declare function getGatewaySessionMessageSubscriptionCoordinator(client: Gateway
209
209
  declare function resetGatewaySessionMessageSubscriptionCoordinator(client: GatewaySessionMessageRequestClient): void;
210
210
  declare function releaseGatewaySessionMessageSubscription(subscription: GatewaySessionMessageSubscription): Promise<void>;
211
211
  //#endregion
212
- export { readAssistantStreamSegmentIdentity as A, SessionProjectionRunTransition as C, SessionMessageEnvelope as D, readSessionProjectionFinalMessageIdentity as E, buildDeviceAuthPayload as F, buildDeviceAuthPayloadV3 as I, normalizeDeviceMetadataForAuth as L, readSessionMessageSequence as M, gatewayCredentialScope as N, SessionMessageIdentity as O, gatewayOriginScope as P, SessionProjectionGatewayRunEvent as S, hasSessionProjectionAcceptedFinal as T, isLocallyOptimisticSessionMessage as _, GatewaySessionMessageSubscriptionOptions as a, reduceSessionProjection as b, resetGatewaySessionMessageSubscriptionCoordinator as c, SessionProjectionRun as d, SessionProjectionRunStatus as f, createSessionProjection as g, SessionProjectionState as h, GatewaySessionMessageSubscriptionCoordinatorOptions as i, readSessionMessageIdentity as j, normalizeSessionProjectionRunId as k, SessionProjectionEntry as l, SessionProjectionSnapshotOptions as m, GatewaySessionMessageSubscription as n, getGatewaySessionMessageSubscriptionCoordinator as o, SessionProjectionScope as p, GatewaySessionMessageSubscriptionCoordinator as r, releaseGatewaySessionMessageSubscription as s, GatewaySessionMessageRequestClient as t, SessionProjectionEvent as u, projectLiveSessionMessage as v, reduceSessionProjectionRunEvent as w, isSessionProjectionErrorMessage as x, reconcileSessionProjectionSnapshot as y };
212
+ export { readAssistantStreamSegmentIdentity as A, hasSessionProjectionAcceptedFinal as C, SessionProjectionEntry as D, SessionMessageIdentity as E, buildDeviceAuthPayload as F, buildDeviceAuthPayloadV3 as I, normalizeDeviceMetadataForAuth as L, readSessionMessageSequence as M, gatewayCredentialScope as N, isLocallyOptimisticSessionMessage as O, gatewayOriginScope as P, reduceSessionProjectionRunEvent as S, SessionMessageEnvelope as T, reconcileSessionProjectionSnapshot as _, GatewaySessionMessageSubscriptionOptions as a, SessionProjectionGatewayRunEvent as b, resetGatewaySessionMessageSubscriptionCoordinator as c, SessionProjectionRunStatus as d, SessionProjectionScope as f, projectLiveSessionMessage as g, createSessionProjection as h, GatewaySessionMessageSubscriptionCoordinatorOptions as i, readSessionMessageIdentity as j, normalizeSessionProjectionRunId as k, SessionProjectionEvent as l, SessionProjectionState as m, GatewaySessionMessageSubscription as n, getGatewaySessionMessageSubscriptionCoordinator as o, SessionProjectionSnapshotOptions as p, GatewaySessionMessageSubscriptionCoordinator as r, releaseGatewaySessionMessageSubscription as s, GatewaySessionMessageRequestClient as t, SessionProjectionRun as u, reduceSessionProjection as v, readSessionProjectionFinalMessageIdentity as w, SessionProjectionRunTransition as x, isSessionProjectionErrorMessage as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/gateway-client",
3
- "version": "2026.9.4",
3
+ "version": "2026.9.5",
4
4
  "description": "Reference WebSocket client for the OpenClaw Gateway protocol",
5
5
  "keywords": [
6
6
  "client",
@@ -62,7 +62,7 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@openclaw/gateway-protocol": "2026.9.4",
65
+ "@openclaw/gateway-protocol": "2026.9.5",
66
66
  "ipaddr.js": "2.5.0",
67
67
  "ws": "8.21.3"
68
68
  },