clawconnect-agent 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +7 -3
  2. package/README.zh-CN.md +7 -3
  3. package/dist/commands/hermes-file-transfer-outcome.d.ts +18 -0
  4. package/dist/commands/hermes-file-transfer-outcome.js +40 -0
  5. package/dist/commands/hermes-file-transfer-outcome.js.map +1 -0
  6. package/dist/commands/send-file.js +39 -33
  7. package/dist/commands/send-file.js.map +1 -1
  8. package/dist/core/command-types.d.ts +5 -0
  9. package/dist/core/relay/chat-payload.js +32 -22
  10. package/dist/core/relay/chat-payload.js.map +1 -1
  11. package/dist/core/relay/file-upload.d.ts +1 -0
  12. package/dist/core/relay/file-upload.js +3 -0
  13. package/dist/core/relay/file-upload.js.map +1 -1
  14. package/dist/core/relay/mobile-chat-run-bridge.d.ts +53 -8
  15. package/dist/core/relay/mobile-chat-run-bridge.js +45 -0
  16. package/dist/core/relay/mobile-chat-run-bridge.js.map +1 -1
  17. package/dist/core/relay/office-payload.js +54 -22
  18. package/dist/core/relay/office-payload.js.map +1 -1
  19. package/dist/core/relay/relay-server-connection.d.ts +1 -0
  20. package/dist/core/relay/relay-server-connection.js +17 -0
  21. package/dist/core/relay/relay-server-connection.js.map +1 -1
  22. package/dist/core/relay/source-commit.d.ts +42 -0
  23. package/dist/core/relay/source-commit.js +127 -0
  24. package/dist/core/relay/source-commit.js.map +1 -0
  25. package/dist/core/relay/timeline-event-builder.d.ts +6 -0
  26. package/dist/core/relay/timeline-event-builder.js +3 -0
  27. package/dist/core/relay/timeline-event-builder.js.map +1 -1
  28. package/dist/core/relay/timeline-event-log.d.ts +29 -0
  29. package/dist/core/relay/timeline-event-log.js +37 -0
  30. package/dist/core/relay/timeline-event-log.js.map +1 -1
  31. package/dist/core/relay/timeline-projection-v3.d.ts +63 -0
  32. package/dist/core/relay/timeline-projection-v3.js +87 -0
  33. package/dist/core/relay/timeline-projection-v3.js.map +1 -0
  34. package/dist/hermes/hermes-relay-manager.js +3 -0
  35. package/dist/hermes/hermes-relay-manager.js.map +1 -1
  36. package/dist/hermes/hermes-runtime-test-support.d.ts +3 -3
  37. package/dist/hermes/hermes-runtime-test-support.js +12 -6
  38. package/dist/hermes/hermes-runtime-test-support.js.map +1 -1
  39. package/dist/hermes/runtime/hermes-file-transfer-state.d.ts +33 -0
  40. package/dist/hermes/runtime/hermes-file-transfer-state.js +139 -0
  41. package/dist/hermes/runtime/hermes-file-transfer-state.js.map +1 -0
  42. package/dist/hermes/runtime/hermes-mobile-file-route-store.d.ts +17 -0
  43. package/dist/hermes/runtime/hermes-mobile-file-route-store.js +103 -0
  44. package/dist/hermes/runtime/hermes-mobile-file-route-store.js.map +1 -0
  45. package/dist/hermes/runtime/hermes-runtime-api-client.d.ts +1 -0
  46. package/dist/hermes/runtime/hermes-runtime-api-client.js +16 -0
  47. package/dist/hermes/runtime/hermes-runtime-api-client.js.map +1 -1
  48. package/dist/hermes/runtime/hermes-runtime-chat.d.ts +35 -4
  49. package/dist/hermes/runtime/hermes-runtime-chat.js +426 -51
  50. package/dist/hermes/runtime/hermes-runtime-chat.js.map +1 -1
  51. package/dist/hermes/runtime/hermes-runtime-command-router.js +9 -1
  52. package/dist/hermes/runtime/hermes-runtime-command-router.js.map +1 -1
  53. package/dist/hermes/runtime/hermes-runtime-cron.d.ts +1 -0
  54. package/dist/hermes/runtime/hermes-runtime-cron.js +34 -66
  55. package/dist/hermes/runtime/hermes-runtime-cron.js.map +1 -1
  56. package/dist/hermes/runtime/hermes-runtime-history-completion.d.ts +8 -1
  57. package/dist/hermes/runtime/hermes-runtime-history-completion.js +112 -41
  58. package/dist/hermes/runtime/hermes-runtime-history-completion.js.map +1 -1
  59. package/dist/hermes/runtime/hermes-runtime-history.js +92 -17
  60. package/dist/hermes/runtime/hermes-runtime-history.js.map +1 -1
  61. package/dist/hermes/runtime/hermes-runtime-models.js +4 -1
  62. package/dist/hermes/runtime/hermes-runtime-models.js.map +1 -1
  63. package/dist/hermes/runtime/hermes-runtime-process.js +4 -3
  64. package/dist/hermes/runtime/hermes-runtime-process.js.map +1 -1
  65. package/dist/hermes/runtime/hermes-runtime-state-db.d.ts +6 -0
  66. package/dist/hermes/runtime/hermes-runtime-state-db.js +20 -1
  67. package/dist/hermes/runtime/hermes-runtime-state-db.js.map +1 -1
  68. package/dist/hermes/runtime/hermes-runtime-tool-log-watcher.js +12 -20
  69. package/dist/hermes/runtime/hermes-runtime-tool-log-watcher.js.map +1 -1
  70. package/dist/index.js +16 -2
  71. package/dist/index.js.map +1 -1
  72. package/dist/openclaw/config/provider-config.js +4 -1
  73. package/dist/openclaw/config/provider-config.js.map +1 -1
  74. package/dist/openclaw/relay/agent-identity-helpers.d.ts +9 -0
  75. package/dist/openclaw/relay/agent-identity-helpers.js +118 -0
  76. package/dist/openclaw/relay/agent-identity-helpers.js.map +1 -0
  77. package/dist/openclaw/relay/assistant-media-sidecar.d.ts +19 -2
  78. package/dist/openclaw/relay/assistant-media-sidecar.js +131 -15
  79. package/dist/openclaw/relay/assistant-media-sidecar.js.map +1 -1
  80. package/dist/openclaw/relay/chat-history.d.ts +37 -2
  81. package/dist/openclaw/relay/chat-history.js +565 -51
  82. package/dist/openclaw/relay/chat-history.js.map +1 -1
  83. package/dist/openclaw/relay/chat-run-identity.js +3 -1
  84. package/dist/openclaw/relay/chat-run-identity.js.map +1 -1
  85. package/dist/openclaw/relay/gateway-history-projection.d.ts +4 -0
  86. package/dist/openclaw/relay/gateway-history-projection.js +67 -0
  87. package/dist/openclaw/relay/gateway-history-projection.js.map +1 -0
  88. package/dist/openclaw/relay/openclaw-active-run-state.d.ts +67 -0
  89. package/dist/openclaw/relay/openclaw-active-run-state.js +283 -0
  90. package/dist/openclaw/relay/openclaw-active-run-state.js.map +1 -0
  91. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.d.ts +12 -0
  92. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.js +48 -0
  93. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.js.map +1 -0
  94. package/dist/openclaw/relay/openclaw-source-commit-watcher.d.ts +23 -0
  95. package/dist/openclaw/relay/openclaw-source-commit-watcher.js +65 -0
  96. package/dist/openclaw/relay/openclaw-source-commit-watcher.js.map +1 -0
  97. package/dist/openclaw/relay/outgoing-media-relay.d.ts +30 -3
  98. package/dist/openclaw/relay/outgoing-media-relay.js +407 -118
  99. package/dist/openclaw/relay/outgoing-media-relay.js.map +1 -1
  100. package/dist/openclaw/relay/relay-manager-history-timing.d.ts +1 -4
  101. package/dist/openclaw/relay/relay-manager-history-timing.js +4 -4
  102. package/dist/openclaw/relay/relay-manager-history-timing.js.map +1 -1
  103. package/dist/openclaw/relay/relay-manager-payload-helpers.d.ts +28 -1
  104. package/dist/openclaw/relay/relay-manager-payload-helpers.js +219 -11
  105. package/dist/openclaw/relay/relay-manager-payload-helpers.js.map +1 -1
  106. package/dist/openclaw/relay/session-context.d.ts +15 -0
  107. package/dist/openclaw/relay/session-context.js +48 -4
  108. package/dist/openclaw/relay/session-context.js.map +1 -1
  109. package/dist/openclaw/relay/source-commit-projection.d.ts +25 -0
  110. package/dist/openclaw/relay/source-commit-projection.js +140 -0
  111. package/dist/openclaw/relay/source-commit-projection.js.map +1 -0
  112. package/dist/openclaw/relay/transcript-dedupe.js +11 -42
  113. package/dist/openclaw/relay/transcript-dedupe.js.map +1 -1
  114. package/dist/openclaw/relay-manager.js +328 -136
  115. package/dist/openclaw/relay-manager.js.map +1 -1
  116. package/dist/openclaw/runtime/local-runtime.js +4 -2
  117. package/dist/openclaw/runtime/local-runtime.js.map +1 -1
  118. package/package.json +1 -1
  119. package/dist/openclaw/session-store.d.ts +0 -6
  120. package/dist/openclaw/session-store.js +0 -231
  121. package/dist/openclaw/session-store.js.map +0 -1
@@ -1,28 +1,39 @@
1
1
  import { OpenClawGatewayClient } from "./gateway-client.js";
2
- import { bindRelayAbortSignal, buildRelayUrl, createRelayWebSocket, disconnectRelaySocketForRecovery, parseRelayFrame, sendRelayJson, sendRelayJsonWithWriteConfirmation, shouldRetryRelayClose, } from "../core/relay/relay-server-connection.js";
2
+ import { bindRelayAbortSignal, buildRelayUrl, createRelayWebSocket, disconnectRelaySocketForRecovery, parseRelayFrame, sendRelayJson, sendRelayJsonWithWriteConfirmation, shouldRetryRelayClose, waitForRelaySocketDrain, } from "../core/relay/relay-server-connection.js";
3
3
  import { handleLocalCommand } from "./handlers/local-handlers.js";
4
4
  import { handleProviderCommand } from "./handlers/provider-handlers.js";
5
5
  import { DEFAULT_GATEWAY_SESSION_DEFAULTS, buildContextUsageFingerprint, contextUsageSnapshotFromSessionsList, readContextUsageSnapshot, canonicalizeRelayParams, canonicalizeSessionKey, extractGatewaySessionDefaults, } from "./relay/session-context.js";
6
6
  import { appendUniqueSuffix, extractChatRole, extractChatText, normalizeChatEventPayload, normalizeChatState, withMessageText, } from "../core/relay/chat-payload.js";
7
- import { canonicalizeOpenClawGatewayHistoryResponse, extractHistoryOutcome, readOpenClawTranscriptChatHistory, withTimeout, } from "./relay/chat-history.js";
7
+ import { canonicalizeOpenClawGatewayHistoryResponse, extractHistoryMediaContent, extractExplicitParentMediaContent, extractHistoryOutcome, readOpenClawSourceCommitCursor, readOpenClawTranscriptChatHistory, withTimeout, } from "./relay/chat-history.js";
8
+ import { watchOpenClawSourceCommit } from "./relay/openclaw-source-commit-watcher.js";
9
+ import { projectSourceCommitHistoryPages } from "./relay/source-commit-projection.js";
10
+ import { join } from "node:path";
11
+ import { resolveOpenClawStateDir } from "./runtime/openclaw-paths.js";
12
+ import { openClawAgentIdFromSessionKey } from "../core/relay/timeline-projection-v3.js";
8
13
  import { OpenClawChatSendDedupeCoordinator, } from "./relay/chat-send-dedupe-coordinator.js";
9
- import { buildMobileAssistantDeltaPayload, buildMobileAssistantErrorPayload, buildMobileAssistantFinalPayload, } from "../core/relay/mobile-chat-run-bridge.js";
14
+ import { buildMobileAssistantDeltaPayload, buildMobileAssistantCompletedPayload, buildMobileAssistantErrorPayload, buildMobileAssistantFinalPayload, } from "../core/relay/mobile-chat-run-bridge.js";
10
15
  import { LatestSnapshotCoalescer, buildAssistantStreamSnapshotKey, } from "../core/relay/assistant-stream-coalescer.js";
11
16
  import { reliableRelayOutboxForGateway } from "../core/relay/reliable-relay-outbox-registry.js";
12
17
  import { isValidRelayHello, reliableDeliveryModeFromRelayHello, RELAY_HELLO_NEGOTIATION_TIMEOUT_MS, } from "../core/relay/reliable-delivery-protocol.js";
13
18
  import { buildChatSendIdempotencyRequest, ChatSendIdempotencyGuard, } from "../core/relay/chat-send-idempotency.js";
14
19
  import { prepareChatSendParams } from "./relay/chat-send-attachments.js";
15
- import { canonicalizeOpenClawAssistantMediaSidecarPayload } from "./relay/assistant-media-sidecar.js";
20
+ import { canonicalizeOpenClawAssistantMediaSidecarPayload, materializeOpenClawDisplayContentPayload, } from "./relay/assistant-media-sidecar.js";
16
21
  import { relayOutgoingMediaInHistoryResponse, relayOutgoingMediaInPayload, } from "./relay/outgoing-media-relay.js";
17
22
  import { prepareOpenClawVoiceInputCommand } from "./relay/openclaw-voice-input.js";
18
23
  import { voiceInputSetupMessage } from "../core/relay/voice-input.js";
19
24
  import { buildRelayHelloMessage } from "./relay/relay-manager-hello.js";
20
25
  import { buildOpenClawHostRuntimeMetadata } from "../runtime-metadata.js";
21
- import { CHAT_HISTORY_FALLBACK_INITIAL_DELAY_MS, CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS, CHAT_HISTORY_FALLBACK_RETRY_DELAY_MS, CHAT_HISTORY_FETCH_TIMEOUT_MS, CHAT_HISTORY_FINAL_RETRY_DELAY_MS, } from "./relay/relay-manager-history-timing.js";
26
+ import { CHAT_HISTORY_FETCH_TIMEOUT_MS, CHAT_HISTORY_MEDIA_ENRICHMENT_TIMEOUT_MS, } from "./relay/relay-manager-history-timing.js";
22
27
  import { canonicalizeOpenClawChatSendResult, normalizeOpenClawMobileRunId, openClawChatRunIdentities, resolveExplicitMobileRunIdFromChatPayload, restoreOpenClawProviderRunIdForCommand, } from "./relay/chat-run-identity.js";
28
+ import { markOpenClawActiveRunTerminal, recordOpenClawActiveRun, updateOpenClawActiveRunProviderId, } from "./relay/openclaw-active-run-state.js";
23
29
  import { publishOfficeSnapshot } from "./relay/relay-manager-office-events.js";
30
+ import { enrichAgentsListWithIdentities } from "./relay/agent-identity-helpers.js";
24
31
  export { buildRelayHelloMessage } from "./relay/relay-manager-hello.js";
25
- import { buildEmptyHistoryPage, buildFinalPayloadFromHistoryOutcome, buildOpenClawGatewayHistoryParams, extractChatErrorMessage, hasHistoryCursor, mergeCanonicalChatPayload, mobileAssistantUsageFromPayload, nonTextContentBlocks, nonTextContentBlocksFromHistory, resolveChatPayloadSeq, resolveChatPayloadTimestamp, } from "./relay/relay-manager-payload-helpers.js";
32
+ import { buildEmptyHistoryPage, buildFinalPayloadFromHistoryOutcome, buildOpenClawGatewayHistoryParams, assistantHistoryReplyMessageId, assistantReplyMessageId, extractChatErrorMessage, extractChatLineage, hasHistoryCursor, mergeCanonicalChatPayload, mobileAssistantUsageFromPayload, mergeNonTextContentBlocks, nonTextContentBlocks, nonTextContentBlocksFromHistory, resolveChatPayloadSeq, resolveChatPayloadTimestamp, } from "./relay/relay-manager-payload-helpers.js";
33
+ function hasCanonicalProjectionV3Snapshot(history) {
34
+ return history?.projectionVersion === 3
35
+ && (history.timelineSnapshot?.messages ?? []).some((message) => message.projectionVersion === 3);
36
+ }
26
37
  // 保留在连接实例之外,使 Relay WebSocket 断线重连后的同轮重投仍能复用终态结果。
27
38
  const openClawChatSendIdempotency = new ChatSendIdempotencyGuard();
28
39
  // ---------------------------------------------------------------------------
@@ -62,7 +73,7 @@ export async function runRelayManager(opts) {
62
73
  let sessionDefaults = { ...DEFAULT_GATEWAY_SESSION_DEFAULTS };
63
74
  const subscribedSessionMessageKeys = new Set();
64
75
  const pendingSessionMessageSubscriptions = new Map();
65
- const chatFallbacks = new Map();
76
+ const sourceCommitWatchers = new Map();
66
77
  const chatRunContexts = new Map();
67
78
  const chatSendDedupe = new OpenClawChatSendDedupeCoordinator(() => sessionDefaults);
68
79
  const outgoingMediaUploadCache = new Map();
@@ -89,11 +100,16 @@ export async function runRelayManager(opts) {
89
100
  },
90
101
  });
91
102
  };
92
- const clearChatFallback = (runId) => {
93
- const timer = chatFallbacks.get(runId);
94
- if (timer) {
95
- clearTimeout(timer);
96
- chatFallbacks.delete(runId);
103
+ const setChatRunContext = (providerRunId, context) => {
104
+ chatRunContexts.set(providerRunId, context);
105
+ // Delivery-mirror terminals can arrive after the provider terminal. Keep
106
+ // enough completed contexts for deterministic history reconciliation;
107
+ // bound the map explicitly instead of using a timer as correctness.
108
+ while (chatRunContexts.size > 2048) {
109
+ const oldest = chatRunContexts.keys().next().value;
110
+ if (!oldest)
111
+ break;
112
+ chatRunContexts.delete(oldest);
97
113
  }
98
114
  };
99
115
  const publishContextUsageSnapshot = async (sessionKey, force = false) => {
@@ -144,73 +160,19 @@ export async function runRelayManager(opts) {
144
160
  timer.unref?.();
145
161
  contextUsageRefreshes.set(key, timer);
146
162
  };
147
- const scheduleChatHistoryFallback = (providerRunId, context, attempt = 0) => {
148
- if (!providerRunId || !context.sessionKey) {
149
- return;
163
+ const explicitOpenClawSessionKey = (rawSessionKey) => {
164
+ const canonical = canonicalizeSessionKey(rawSessionKey, sessionDefaults);
165
+ const normalized = typeof canonical === "string" ? canonical.trim() : "";
166
+ if (normalized === "main") {
167
+ return `agent:${sessionDefaults.defaultAgentId ?? "main"}:${sessionDefaults.mainKey || "main"}`;
150
168
  }
151
- clearChatFallback(providerRunId);
152
- chatRunContexts.set(providerRunId, context);
153
- const timer = setTimeout(() => {
154
- if (!gatewayClient) {
155
- chatFallbacks.delete(providerRunId);
156
- return;
157
- }
158
- const fetchHistory = () => requestChatHistoryFromClawConnect({ sessionKey: context.sessionKey, limit: 10 });
159
- withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history fallback")
160
- .then(async (history) => {
161
- const outcome = extractHistoryOutcome(history, context);
162
- if ((!outcome || isOpenClawMediaDisplayPlaceholder(outcome.kind === "final" ? outcome.text : ""))
163
- && attempt < CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS) {
164
- scheduleChatHistoryFallback(providerRunId, context, attempt + 1);
165
- return;
166
- }
167
- if (!outcome) {
168
- chatFallbacks.delete(providerRunId);
169
- chatRunContexts.delete(providerRunId);
170
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
171
- return;
172
- }
173
- clearChatFallback(providerRunId);
174
- chatRunContexts.delete(providerRunId);
175
- if (outcome.kind === "final") {
176
- openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
177
- const basePayload = buildMobileAssistantFinalPayload({
178
- run: { runId: context.canonicalRunId, sessionKey: context.sessionKey },
179
- text: outcome.text,
180
- includeTimelineEvents: true,
181
- });
182
- await publishAndSendGatewayEvent("chat", buildFinalPayloadFromHistoryOutcome(basePayload, outcome), true, context.promptText);
183
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
184
- return;
185
- }
186
- openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
187
- await publishAndSendGatewayEvent("chat", buildMobileAssistantErrorPayload({
188
- run: { runId: context.canonicalRunId, sessionKey: context.sessionKey },
189
- errorMessage: outcome.errorMessage,
190
- includeTimelineEvents: true,
191
- }), true);
192
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
193
- })
194
- .catch((err) => {
195
- if (attempt < CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS) {
196
- scheduleChatHistoryFallback(providerRunId, context, attempt + 1);
197
- return;
198
- }
199
- console.warn(`[relay] chat history fallback failed runId=${providerRunId}: ${String(err)}`);
200
- chatFallbacks.delete(providerRunId);
201
- chatRunContexts.delete(providerRunId);
202
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
203
- });
204
- }, attempt === 0 ? CHAT_HISTORY_FALLBACK_INITIAL_DELAY_MS : CHAT_HISTORY_FALLBACK_RETRY_DELAY_MS);
205
- timer.unref?.();
206
- chatFallbacks.set(providerRunId, timer);
169
+ return normalized || rawSessionKey.trim();
207
170
  };
208
171
  const ensureSessionMessagesSubscribed = async (sessionKey) => {
209
- const normalizedSessionKey = canonicalizeSessionKey(sessionKey, sessionDefaults);
210
- if (typeof normalizedSessionKey !== "string" || normalizedSessionKey.trim().length === 0) {
172
+ const key = explicitOpenClawSessionKey(sessionKey);
173
+ if (!key) {
211
174
  throw new Error("cannot subscribe to an empty OpenClaw session key");
212
175
  }
213
- const key = normalizedSessionKey.trim();
214
176
  if (subscribedSessionMessageKeys.has(key)) {
215
177
  return;
216
178
  }
@@ -249,6 +211,7 @@ export async function runRelayManager(opts) {
249
211
  sessionDefaults = nextDefaults;
250
212
  }
251
213
  await ensureSessionMessagesSubscribed(sessionDefaults.mainSessionKey);
214
+ ensureSourceCommitWatcher(sessionDefaults.mainSessionKey, "latest");
252
215
  const sessionsPayload = await gatewayClient.request("sessions.list", {
253
216
  limit: 100,
254
217
  includeGlobal: true,
@@ -264,6 +227,13 @@ export async function runRelayManager(opts) {
264
227
  : undefined;
265
228
  if (typeof sessionKey !== "string" || !sessionKey.trim())
266
229
  continue;
230
+ if (/(?:^|:)dashboard:/i.test(sessionKey))
231
+ continue;
232
+ // Cron jobs can target an agent session other than the default one.
233
+ // Subscribe each known session so their finished replies travel through
234
+ // the same ClawConnect → Relay realtime path as an interactive chat.
235
+ await ensureSessionMessagesSubscribed(sessionKey);
236
+ ensureSourceCommitWatcher(sessionKey, "latest");
267
237
  const snapshot = contextUsageSnapshotFromSessionsList(sessionsPayload, sessionKey, sessionDefaults);
268
238
  if (!snapshot)
269
239
  continue;
@@ -336,6 +306,7 @@ export async function runRelayManager(opts) {
336
306
  cache: outgoingMediaUploadCache,
337
307
  userMessage,
338
308
  waitForOutgoingMediaRecord: true,
309
+ sessionDefaults,
339
310
  });
340
311
  }
341
312
  catch (error) {
@@ -371,14 +342,14 @@ export async function runRelayManager(opts) {
371
342
  console.warn(`[relay] assistant snapshot publish failed key=${key}: ${String(error)}`);
372
343
  },
373
344
  });
374
- const assistantSnapshotKey = (runId, sessionKey) => {
345
+ const assistantSnapshotKey = (runId, sessionKey, messageId) => {
375
346
  if (!runId?.trim() || !sessionKey?.trim()) {
376
347
  return undefined;
377
348
  }
378
349
  return buildAssistantStreamSnapshotKey({
379
350
  sessionKey,
380
351
  runId,
381
- messageId: `assistant-${runId}`,
352
+ ...(messageId ? { messageId } : { messageId: `assistant-${runId}` }),
382
353
  });
383
354
  };
384
355
  const runAfterAssistantSnapshot = (key, close, operation, releaseClosed = false) => {
@@ -419,7 +390,7 @@ export async function runRelayManager(opts) {
419
390
  const sessionKey = typeof record.sessionKey === "string" && record.sessionKey.trim()
420
391
  ? record.sessionKey.trim()
421
392
  : runContext?.sessionKey;
422
- return assistantSnapshotKey(runId, sessionKey);
393
+ return assistantSnapshotKey(runId, sessionKey, runId ? assistantReplyMessageId(payload, runId) : undefined);
423
394
  };
424
395
  async function relayOutgoingMediaForResponse(method, payload) {
425
396
  if (method !== "chat.history") {
@@ -431,12 +402,23 @@ export async function runRelayManager(opts) {
431
402
  gatewayId: opts.gatewayId,
432
403
  senderDisplayName: "OpenClaw",
433
404
  cache: outgoingMediaUploadCache,
405
+ sessionDefaults,
434
406
  });
435
407
  }
436
408
  async function requestChatHistoryFromClawConnect(params) {
409
+ // Relay-owned history reads opt into the v3 projection contract. The
410
+ // public legacy helper remains v2-compatible for older callers during
411
+ // rollout; every confirmed row consumed by this manager is v3.
412
+ const projectionParams = params && typeof params === "object" && !Array.isArray(params)
413
+ ? {
414
+ ...params,
415
+ projectionVersion: 3,
416
+ projectionGatewayId: opts.gatewayId,
417
+ }
418
+ : { projectionVersion: 3, projectionGatewayId: opts.gatewayId };
437
419
  try {
438
- await chatSendDedupe.dedupePendingForSession(params);
439
- const transcriptHistory = await readOpenClawTranscriptChatHistory(params, sessionDefaults);
420
+ await chatSendDedupe.dedupePendingForSession(projectionParams);
421
+ const transcriptHistory = await readOpenClawTranscriptChatHistory(projectionParams, sessionDefaults);
440
422
  if (transcriptHistory) {
441
423
  return transcriptHistory;
442
424
  }
@@ -445,7 +427,7 @@ export async function runRelayManager(opts) {
445
427
  console.warn(`[relay] transcript chat.history unavailable: ${String(err)}`);
446
428
  }
447
429
  if (hasHistoryCursor(params)) {
448
- return buildEmptyHistoryPage(params, sessionDefaults);
430
+ return buildEmptyHistoryPage(projectionParams, sessionDefaults);
449
431
  }
450
432
  if (!gatewayClient) {
451
433
  throw new Error("gateway not connected");
@@ -454,8 +436,58 @@ export async function runRelayManager(opts) {
454
436
  const history = await gatewayClient.request("chat.history", gatewayHistoryParams);
455
437
  return canonicalizeOpenClawGatewayHistoryResponse(history, {
456
438
  sessionKey: String(gatewayHistoryParams.sessionKey),
439
+ projectionVersion: 3,
440
+ projectionGatewayId: opts.gatewayId,
441
+ sessionDefaults,
457
442
  });
458
443
  }
444
+ const ensureSourceCommitWatcher = (sessionKey, initialWatermarkMode = "from_zero") => {
445
+ const normalizedSessionKey = explicitOpenClawSessionKey(sessionKey);
446
+ if (!normalizedSessionKey)
447
+ return;
448
+ const key = `${opts.gatewayId}\u0000${normalizedSessionKey}`;
449
+ if (sourceCommitWatchers.has(key))
450
+ return;
451
+ const agentId = openClawAgentIdFromSessionKey(normalizedSessionKey);
452
+ const databasePath = join(resolveOpenClawStateDir(), "agents", agentId, "agent", "openclaw-agent.sqlite");
453
+ const watcher = watchOpenClawSourceCommit({
454
+ databasePath,
455
+ initialWatermarkMode,
456
+ readCursor: () => readOpenClawSourceCommitCursor({
457
+ sessionKey: normalizedSessionKey,
458
+ projectionVersion: 3,
459
+ projectionGatewayId: opts.gatewayId,
460
+ }, sessionDefaults),
461
+ onCommit: async (sourceCommit, previousCommittedThroughSeq) => {
462
+ await projectSourceCommitHistoryPages({
463
+ sourceCommit,
464
+ previousCommittedThroughSeq,
465
+ readPage: (cursorSeq) => withTimeout(requestChatHistoryFromClawConnect({
466
+ sessionKey: normalizedSessionKey,
467
+ limit: 200,
468
+ cursor: `seq:${cursorSeq}`,
469
+ direction: "newer",
470
+ }), CHAT_HISTORY_FETCH_TIMEOUT_MS, "source commit history reconciliation"),
471
+ publish: async ({ sourceCommit: pageSourceCommit, events }) => {
472
+ await waitForRelaySocketDrain(relayWs, 256 * 1024);
473
+ const deliveryResult = await publishAndSendGatewayEvent("chat", {
474
+ state: "source_commit",
475
+ sessionKey: normalizedSessionKey,
476
+ sourceCommit: pageSourceCommit,
477
+ timelineEvents: events,
478
+ }, true, undefined, true);
479
+ if (deliveryResult?.status === "retryable") {
480
+ throw deliveryResult.error;
481
+ }
482
+ },
483
+ });
484
+ },
485
+ onError: (error) => {
486
+ console.warn(`[relay] source commit reconciliation failed session=${normalizedSessionKey}: ${String(error)}`);
487
+ },
488
+ });
489
+ sourceCommitWatchers.set(key, watcher);
490
+ };
459
491
  relayWs.on("open", () => {
460
492
  console.log(`Connected to relay server (gatewayId=${opts.gatewayId})`);
461
493
  opts.onConnected?.();
@@ -497,8 +529,28 @@ export async function runRelayManager(opts) {
497
529
  });
498
530
  },
499
531
  onEvent: (event, payload) => {
532
+ if (event === "session.message") {
533
+ // OpenClaw emits this subscription event after the transcript
534
+ // append has committed. It is the primary wake-up for source
535
+ // reconciliation; SQLite notifications and the low-frequency
536
+ // anti-entropy scan remain recovery paths for dropped events.
537
+ const record = payload && typeof payload === "object" && !Array.isArray(payload)
538
+ ? payload
539
+ : undefined;
540
+ const rawSessionKey = typeof record?.sessionKey === "string"
541
+ ? record.sessionKey.trim()
542
+ : "";
543
+ if (rawSessionKey) {
544
+ const sessionKey = canonicalizeSessionKey(rawSessionKey, sessionDefaults);
545
+ if (typeof sessionKey === "string" && sessionKey.trim()) {
546
+ ensureSourceCommitWatcher(sessionKey, "from_zero");
547
+ sourceCommitWatchers.get(`${opts.gatewayId}\u0000${sessionKey.trim()}`)?.notify();
548
+ }
549
+ }
550
+ return;
551
+ }
500
552
  const normalizedPayload = event === "chat"
501
- ? canonicalizeOpenClawAssistantMediaSidecarPayload(normalizeChatEventPayload(payload))
553
+ ? materializeOpenClawDisplayContentPayload(canonicalizeOpenClawAssistantMediaSidecarPayload(normalizeChatEventPayload(payload)))
502
554
  : payload;
503
555
  const shouldPublishOffice = event === "chat" || event === "agent" || event === "context_usage";
504
556
  if (event === "chat") {
@@ -509,10 +561,16 @@ export async function runRelayManager(opts) {
509
561
  const isTerminalState = state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted";
510
562
  const sessionKey = typeof p?.sessionKey === "string" && p.sessionKey.trim().length > 0 ? p.sessionKey.trim() : undefined;
511
563
  const explicitCanonicalRunId = resolveExplicitMobileRunIdFromChatPayload(normalizedPayload);
564
+ const currentText = extractChatText(normalizedPayload);
565
+ const role = extractChatRole(normalizedPayload);
512
566
  let runContext = providerRunId
513
567
  ? chatRunContexts.get(providerRunId) ?? openClawChatRunIdentities.resolve(opts.gatewayId, providerRunId)
514
568
  : undefined;
515
- if (!runContext && providerRunId && sessionKey) {
569
+ // Event-only registration is safe only after the provider proves
570
+ // assistant provenance. A roleless terminal may contain a user
571
+ // echo or an unrelated provider event and must not create a run
572
+ // context from its text/session fields.
573
+ if (!runContext && providerRunId && sessionKey && role === "assistant") {
516
574
  runContext = {
517
575
  gatewayId: opts.gatewayId,
518
576
  providerRunId,
@@ -520,19 +578,37 @@ export async function runRelayManager(opts) {
520
578
  sessionKey,
521
579
  };
522
580
  openClawChatRunIdentities.ensure(runContext);
523
- chatRunContexts.set(providerRunId, runContext);
524
- }
525
- if (providerRunId && isStreamingState && openClawChatRunIdentities.isTerminal(opts.gatewayId, providerRunId)) {
526
- return;
581
+ setChatRunContext(providerRunId, runContext);
527
582
  }
528
583
  if (providerRunId && isTerminalState) {
529
584
  openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
530
585
  }
531
586
  const canonicalRunId = runContext?.canonicalRunId ?? explicitCanonicalRunId ?? providerRunId;
532
587
  const resolvedSessionKey = sessionKey ?? runContext?.sessionKey;
533
- const currentText = extractChatText(normalizedPayload);
534
- const role = extractChatRole(normalizedPayload);
535
- const runSnapshotKey = assistantSnapshotKey(canonicalRunId, resolvedSessionKey);
588
+ if (isTerminalState && runContext && canonicalRunId && resolvedSessionKey) {
589
+ void markOpenClawActiveRunTerminal({
590
+ gatewayId: opts.gatewayId,
591
+ sessionKey: resolvedSessionKey,
592
+ sourceRunId: canonicalRunId,
593
+ }).catch((error) => {
594
+ console.warn(`[relay] failed to mark OpenClaw run terminal: ${String(error)}`);
595
+ });
596
+ }
597
+ const replyMessageId = role === "assistant" && canonicalRunId
598
+ ? assistantReplyMessageId(normalizedPayload, canonicalRunId)
599
+ : undefined;
600
+ const chatLineage = extractChatLineage(normalizedPayload);
601
+ const runSnapshotKey = assistantSnapshotKey(canonicalRunId, resolvedSessionKey, replyMessageId);
602
+ // OpenClaw's message tool may emit several independent assistant
603
+ // replies after a provider-level terminal snapshot. Keep filtering
604
+ // late stream deltas for the run-level text reply, but let a reply
605
+ // with its own upstream message/tool/media identity through.
606
+ if (providerRunId
607
+ && isStreamingState
608
+ && openClawChatRunIdentities.isTerminal(opts.gatewayId, providerRunId)
609
+ && !replyMessageId) {
610
+ return;
611
+ }
536
612
  if (isOpenClawHeartbeatText(currentText) || (runContext?.promptText && isOpenClawHeartbeatText(runContext.promptText))) {
537
613
  if (providerRunId && (state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
538
614
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
@@ -543,6 +619,51 @@ export async function runRelayManager(opts) {
543
619
  }
544
620
  return;
545
621
  }
622
+ // A roleless error/abort is still a terminal lifecycle signal when
623
+ // it belongs to a previously registered provider run. Its identity
624
+ // comes from that run context; never infer it from the payload
625
+ // text. A roleless empty final is lifecycle-only and leaves source
626
+ // rows to the transcript/source-commit watcher. Text-bearing
627
+ // roleless deltas/finals remain rejected user/source echoes.
628
+ if (role !== "assistant") {
629
+ if (!runContext || !providerRunId || !canonicalRunId || !resolvedSessionKey) {
630
+ return;
631
+ }
632
+ if (state === "error" || state === "failed" || state === "fail" || state === "aborted") {
633
+ chatSendDedupe.scheduleForRun(providerRunId, 100);
634
+ chatRunContexts.delete(providerRunId);
635
+ runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
636
+ await publishAndSendGatewayEvent(event, mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantErrorPayload({
637
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
638
+ errorMessage: extractChatErrorMessage(normalizedPayload),
639
+ includeTimelineEvents: true,
640
+ })), shouldPublishOffice, runContext?.promptText);
641
+ openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
642
+ }, true);
643
+ return;
644
+ }
645
+ if (state === "final" && !currentText) {
646
+ chatRunContexts.delete(providerRunId);
647
+ const lifecyclePayload = normalizedPayload && typeof normalizedPayload === "object" && !Array.isArray(normalizedPayload)
648
+ ? (() => {
649
+ const payload = { ...normalizedPayload };
650
+ delete payload.message;
651
+ delete payload.content;
652
+ delete payload.text;
653
+ delete payload.delta;
654
+ return payload;
655
+ })()
656
+ : normalizedPayload;
657
+ runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
658
+ await publishAndSendGatewayEvent(event, mergeCanonicalChatPayload(lifecyclePayload, buildMobileAssistantCompletedPayload({
659
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
660
+ includeTimelineEvents: true,
661
+ })), shouldPublishOffice, runContext?.promptText);
662
+ openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
663
+ }, true);
664
+ }
665
+ return;
666
+ }
546
667
  let realtimePayload = providerRunId && canonicalRunId && canonicalRunId !== providerRunId
547
668
  ? {
548
669
  ...normalizedPayload,
@@ -554,9 +675,6 @@ export async function runRelayManager(opts) {
554
675
  if (state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted") {
555
676
  chatSendDedupe.scheduleForRun(providerRunId, 100);
556
677
  }
557
- if (role === "assistant" && (state === "delta" || state === "final" || state === "error" || state === "failed" || state === "fail")) {
558
- clearChatFallback(providerRunId);
559
- }
560
678
  if (state === "delta" || state === "streaming" || state === "in_progress") {
561
679
  const previousText = openClawChatRunIdentities.accumulatedText(opts.gatewayId, providerRunId);
562
680
  const bufferedText = appendUniqueSuffix(previousText, currentText);
@@ -568,6 +686,7 @@ export async function runRelayManager(opts) {
568
686
  seq: resolveChatPayloadSeq(normalizedPayload),
569
687
  timestampMs: resolveChatPayloadTimestamp(normalizedPayload),
570
688
  delta: bufferedText,
689
+ ...chatLineage,
571
690
  includeTimelineEvents: true,
572
691
  }))
573
692
  : withMessageText(normalizedPayload, bufferedText);
@@ -595,27 +714,79 @@ export async function runRelayManager(opts) {
595
714
  });
596
715
  return;
597
716
  }
598
- if (state === "final" && resolvedSessionKey) {
717
+ if (state === "final" && resolvedSessionKey && role === "assistant") {
599
718
  scheduleContextUsageRefresh(resolvedSessionKey, 450);
600
719
  const bufferedText = providerRunId
601
720
  ? openClawChatRunIdentities.accumulatedText(opts.gatewayId, providerRunId)
602
721
  : "";
603
722
  const resolvedText = appendUniqueSuffix(bufferedText, currentText);
604
723
  if (resolvedText.trim() && !isOpenClawMediaDisplayPlaceholder(resolvedText)) {
605
- const outgoingPayload = providerRunId
606
- ? mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
607
- run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
608
- text: resolvedText,
609
- contentBlocks: nonTextContentBlocks(normalizedPayload),
610
- includeTimelineEvents: true,
611
- ...mobileAssistantUsageFromPayload(normalizedPayload),
612
- }))
613
- : withMessageText(normalizedPayload, resolvedText);
614
- runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
724
+ // A provider-level terminal without a reply identity stays
725
+ // open for later text-only delivery terminals. Each such
726
+ // terminal re-reads the current turn history and reconciles
727
+ // the canonical media snapshot deterministically.
728
+ runAfterAssistantSnapshot(runSnapshotKey, Boolean(replyMessageId), async () => {
729
+ let outgoingPayload;
730
+ const directContentBlocks = nonTextContentBlocks(normalizedPayload);
731
+ if (providerRunId && runContext) {
732
+ try {
733
+ const history = await withTimeout(requestChatHistoryFromClawConnect({ sessionKey: resolvedSessionKey, limit: 10 }), CHAT_HISTORY_MEDIA_ENRICHMENT_TIMEOUT_MS, "chat.history media enrichment");
734
+ let outcome = extractHistoryOutcome(history, runContext, currentText);
735
+ // A message-tool delivery can reach the gateway as a
736
+ // text-only terminal before OpenClaw commits its
737
+ // display-media projection. Always inspect the whole
738
+ // matched turn, not just the selected outcome row, so
739
+ // either a direct image or a later delivery row can
740
+ // keep the terminal media-complete.
741
+ let historyContentBlocks = outcome?.kind === "final"
742
+ ? hasCanonicalProjectionV3Snapshot(history)
743
+ ? mergeNonTextContentBlocks(nonTextContentBlocksFromHistory(outcome.message), extractExplicitParentMediaContent(history, outcome.message))
744
+ : extractHistoryMediaContent(history, runContext)
745
+ : [];
746
+ if (outcome?.kind === "final" && historyContentBlocks.length > 0) {
747
+ // A text terminal is not guaranteed to carry the
748
+ // message-tool identity. Recover it from the stable
749
+ // transcript delivery row before building timeline
750
+ // events; otherwise every sidecar in the run is
751
+ // projected onto assistant-${runId}.
752
+ const historyReplyMessageId = assistantHistoryReplyMessageId(outcome.message, canonicalRunId);
753
+ const mergedContentBlocks = mergeNonTextContentBlocks(directContentBlocks, historyContentBlocks);
754
+ const basePayload = mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
755
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
756
+ text: resolvedText,
757
+ contentBlocks: mergedContentBlocks,
758
+ ...chatLineage,
759
+ ...((replyMessageId ?? historyReplyMessageId)
760
+ ? { messageId: replyMessageId ?? historyReplyMessageId }
761
+ : {}),
762
+ includeTimelineEvents: true,
763
+ ...mobileAssistantUsageFromPayload(normalizedPayload),
764
+ }));
765
+ outgoingPayload = buildFinalPayloadFromHistoryOutcome(basePayload, outcome, [{ type: "text", text: outcome.text }, ...mergedContentBlocks]);
766
+ }
767
+ }
768
+ catch (error) {
769
+ // Media enrichment is opportunistic. The final text event
770
+ // must still reach the client when history is unavailable.
771
+ console.warn(`[relay] terminal media history enrichment skipped: ${String(error)}`);
772
+ }
773
+ }
774
+ if (outgoingPayload === undefined) {
775
+ outgoingPayload = providerRunId
776
+ ? mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
777
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
778
+ text: resolvedText,
779
+ contentBlocks: directContentBlocks,
780
+ ...chatLineage,
781
+ ...(replyMessageId ? { messageId: replyMessageId } : {}),
782
+ includeTimelineEvents: true,
783
+ ...mobileAssistantUsageFromPayload(normalizedPayload),
784
+ }))
785
+ : withMessageText(normalizedPayload, resolvedText);
786
+ }
615
787
  await publishAndSendGatewayEvent(event, outgoingPayload, shouldPublishOffice, runContext?.promptText);
616
788
  if (providerRunId) {
617
789
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
618
- chatRunContexts.delete(providerRunId);
619
790
  }
620
791
  }, Boolean(providerRunId));
621
792
  return;
@@ -623,34 +794,39 @@ export async function runRelayManager(opts) {
623
794
  const fetchHistory = () => requestChatHistoryFromClawConnect({ sessionKey: resolvedSessionKey, limit: 10 });
624
795
  const publishHistoryTerminal = () => withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history")
625
796
  .then(async (history) => {
626
- let outcome = runContext ? extractHistoryOutcome(history, runContext) : null;
627
- // Retry once if OpenClaw has emitted final before the transcript is committed.
628
- if (!outcome || isOpenClawMediaDisplayPlaceholder(outcome.kind === "final" ? outcome.text : "")) {
629
- await new Promise((resolve) => setTimeout(resolve, CHAT_HISTORY_FINAL_RETRY_DELAY_MS));
630
- const retryHistory = await withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history retry");
631
- outcome = runContext ? extractHistoryOutcome(retryHistory, runContext) : null;
632
- }
797
+ // The terminal event is the only completion signal. Do not
798
+ // turn transcript visibility into a wall-clock race: a
799
+ // later provider terminal/history page will re-enter this
800
+ // path and reconcile the newly committed source rows.
801
+ const outcome = runContext ? extractHistoryOutcome(history, runContext, currentText) : null;
633
802
  if (providerRunId && outcome) {
634
- chatRunContexts.delete(providerRunId);
635
803
  }
636
804
  if (outcome?.kind === "final") {
637
- if (isOpenClawMediaDisplayPlaceholder(outcome.text) && providerRunId && runContext) {
638
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
639
- return;
640
- }
805
+ const historyReplyMessageId = providerRunId
806
+ ? assistantHistoryReplyMessageId(outcome.message, canonicalRunId)
807
+ : undefined;
641
808
  const basePayload = providerRunId
642
809
  ? buildMobileAssistantFinalPayload({
643
810
  run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
644
811
  text: outcome.text,
645
- contentBlocks: nonTextContentBlocksFromHistory(outcome.message),
812
+ ...chatLineage,
813
+ contentBlocks: runContext
814
+ ? hasCanonicalProjectionV3Snapshot(history)
815
+ ? mergeNonTextContentBlocks(nonTextContentBlocksFromHistory(outcome.message), extractExplicitParentMediaContent(history, outcome.message))
816
+ : extractHistoryMediaContent(history, runContext)
817
+ : nonTextContentBlocksFromHistory(outcome.message),
818
+ ...((replyMessageId ?? historyReplyMessageId)
819
+ ? { messageId: replyMessageId ?? historyReplyMessageId }
820
+ : {}),
646
821
  includeTimelineEvents: true,
647
822
  ...mobileAssistantUsageFromPayload(normalizedPayload),
648
823
  })
649
824
  : normalizedPayload;
650
825
  const outgoingPayload = buildFinalPayloadFromHistoryOutcome(basePayload, outcome);
651
826
  await publishAndSendGatewayEvent(event, outgoingPayload, shouldPublishOffice, runContext?.promptText);
652
- if (providerRunId)
827
+ if (providerRunId) {
653
828
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
829
+ }
654
830
  return;
655
831
  }
656
832
  if (outcome?.kind === "error") {
@@ -670,21 +846,13 @@ export async function runRelayManager(opts) {
670
846
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
671
847
  return;
672
848
  }
673
- if (providerRunId && runContext) {
674
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
675
- return;
676
- }
677
849
  await publishAndSendGatewayEvent(event, normalizedPayload, shouldPublishOffice);
678
850
  })
679
851
  .catch((err) => {
680
852
  console.error(`[relay] chat.history fetch failed: ${err}`);
681
- if (providerRunId && runContext) {
682
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
683
- return;
684
- }
685
853
  void publishAndSendGatewayEvent(event, normalizedPayload, shouldPublishOffice);
686
854
  });
687
- runAfterAssistantSnapshot(runSnapshotKey, true, publishHistoryTerminal, Boolean(providerRunId));
855
+ runAfterAssistantSnapshot(runSnapshotKey, Boolean(replyMessageId), publishHistoryTerminal, Boolean(providerRunId));
688
856
  return;
689
857
  }
690
858
  if (providerRunId && (state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
@@ -857,6 +1025,22 @@ export async function runRelayManager(opts) {
857
1025
  // thinking/tool/text event cannot win the race with this request.
858
1026
  await ensureSessionMessagesSubscribed(commandSessionKey);
859
1027
  }
1028
+ const sourceRunIdForState = commandMethod === "chat.send"
1029
+ ? normalizeOpenClawMobileRunId(paramsRecord?.idempotencyKey)
1030
+ ?? normalizeOpenClawMobileRunId(voiceInputRun?.runId)
1031
+ ?? requestId
1032
+ : normalizeOpenClawMobileRunId(paramsRecord?.runId)
1033
+ ?? normalizeOpenClawMobileRunId(paramsRecord?.idempotencyKey)
1034
+ ?? requestId;
1035
+ if ((commandMethod === "chat.send" || commandMethod === "agent") && sourceRunIdForState) {
1036
+ await recordOpenClawActiveRun({
1037
+ gatewayId: opts.gatewayId,
1038
+ sessionKey: typeof paramsRecord?.sessionKey === "string" && paramsRecord.sessionKey.trim()
1039
+ ? paramsRecord.sessionKey.trim()
1040
+ : sessionDefaults.mainSessionKey,
1041
+ sourceRunId: sourceRunIdForState,
1042
+ });
1043
+ }
860
1044
  const registerIdentity = (result) => {
861
1045
  const resultRecord = result && typeof result === "object" && !Array.isArray(result)
862
1046
  ? result
@@ -884,11 +1068,19 @@ export async function runRelayManager(opts) {
884
1068
  : undefined,
885
1069
  };
886
1070
  openClawChatRunIdentities.register(identity);
887
- chatRunContexts.set(identity.providerRunId, identity);
1071
+ setChatRunContext(identity.providerRunId, identity);
1072
+ void updateOpenClawActiveRunProviderId({
1073
+ gatewayId: opts.gatewayId,
1074
+ sessionKey: identity.sessionKey,
1075
+ sourceRunId: identity.canonicalRunId,
1076
+ providerRunId: identity.providerRunId,
1077
+ }).catch((error) => {
1078
+ console.warn(`[relay] failed to update OpenClaw active run provider id: ${String(error)}`);
1079
+ });
888
1080
  return identity;
889
1081
  };
890
1082
  let identity;
891
- const result = commandMethod === "chat.history"
1083
+ let result = commandMethod === "chat.history"
892
1084
  ? await requestChatHistoryFromClawConnect(params)
893
1085
  : await gatewayClient.request(commandMethod, params, {
894
1086
  // response 帧内同步登记,保证紧随其后的首个 delta 已能解析 canonical 身份。
@@ -897,6 +1089,9 @@ export async function runRelayManager(opts) {
897
1089
  },
898
1090
  });
899
1091
  identity ??= registerIdentity(result);
1092
+ if (commandMethod === "agents.list" && gatewayClient) {
1093
+ result = await enrichAgentsListWithIdentities(result, gatewayClient);
1094
+ }
900
1095
  return { params, result, identity };
901
1096
  };
902
1097
  const commandExecution = chatSendIdempotencyRequest
@@ -926,9 +1121,7 @@ export async function runRelayManager(opts) {
926
1121
  const sessionKey = typeof paramsRecord?.sessionKey === "string" && paramsRecord.sessionKey.trim().length > 0
927
1122
  ? paramsRecord.sessionKey.trim()
928
1123
  : sessionDefaults.mainSessionKey;
929
- if (providerRunId && identity) {
930
- scheduleChatHistoryFallback(providerRunId, identity);
931
- }
1124
+ ensureSourceCommitWatcher(sessionKey, "from_zero");
932
1125
  if (commandMethod === "chat.send" && chatSendDedupeRequest) {
933
1126
  chatSendDedupe.register(chatSendDedupeRequest, providerRunId);
934
1127
  }
@@ -980,10 +1173,9 @@ export async function runRelayManager(opts) {
980
1173
  opts.onDisconnected?.();
981
1174
  gatewayClient?.stop();
982
1175
  gatewayClient = null;
983
- for (const timer of chatFallbacks.values()) {
984
- clearTimeout(timer);
985
- }
986
- chatFallbacks.clear();
1176
+ for (const watcher of sourceCommitWatchers.values())
1177
+ watcher.close();
1178
+ sourceCommitWatchers.clear();
987
1179
  chatSendDedupe.clearAll();
988
1180
  for (const timer of contextUsageRefreshes.values()) {
989
1181
  clearTimeout(timer);