clawconnect-agent 0.2.5 → 0.2.7

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 (118) 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 +3 -0
  12. package/dist/core/relay/file-upload.js +4 -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/source-commit.d.ts +41 -0
  20. package/dist/core/relay/source-commit.js +118 -0
  21. package/dist/core/relay/source-commit.js.map +1 -0
  22. package/dist/core/relay/timeline-event-builder.d.ts +6 -0
  23. package/dist/core/relay/timeline-event-builder.js +3 -0
  24. package/dist/core/relay/timeline-event-builder.js.map +1 -1
  25. package/dist/core/relay/timeline-event-log.d.ts +29 -0
  26. package/dist/core/relay/timeline-event-log.js +37 -0
  27. package/dist/core/relay/timeline-event-log.js.map +1 -1
  28. package/dist/core/relay/timeline-projection-v3.d.ts +63 -0
  29. package/dist/core/relay/timeline-projection-v3.js +87 -0
  30. package/dist/core/relay/timeline-projection-v3.js.map +1 -0
  31. package/dist/hermes/hermes-relay-manager.js +3 -0
  32. package/dist/hermes/hermes-relay-manager.js.map +1 -1
  33. package/dist/hermes/hermes-runtime-test-support.d.ts +3 -3
  34. package/dist/hermes/hermes-runtime-test-support.js +12 -6
  35. package/dist/hermes/hermes-runtime-test-support.js.map +1 -1
  36. package/dist/hermes/runtime/hermes-file-transfer-state.d.ts +33 -0
  37. package/dist/hermes/runtime/hermes-file-transfer-state.js +139 -0
  38. package/dist/hermes/runtime/hermes-file-transfer-state.js.map +1 -0
  39. package/dist/hermes/runtime/hermes-mobile-file-route-store.d.ts +17 -0
  40. package/dist/hermes/runtime/hermes-mobile-file-route-store.js +103 -0
  41. package/dist/hermes/runtime/hermes-mobile-file-route-store.js.map +1 -0
  42. package/dist/hermes/runtime/hermes-runtime-api-client.d.ts +1 -0
  43. package/dist/hermes/runtime/hermes-runtime-api-client.js +16 -0
  44. package/dist/hermes/runtime/hermes-runtime-api-client.js.map +1 -1
  45. package/dist/hermes/runtime/hermes-runtime-chat.d.ts +35 -4
  46. package/dist/hermes/runtime/hermes-runtime-chat.js +426 -51
  47. package/dist/hermes/runtime/hermes-runtime-chat.js.map +1 -1
  48. package/dist/hermes/runtime/hermes-runtime-command-router.js +9 -1
  49. package/dist/hermes/runtime/hermes-runtime-command-router.js.map +1 -1
  50. package/dist/hermes/runtime/hermes-runtime-cron.d.ts +1 -0
  51. package/dist/hermes/runtime/hermes-runtime-cron.js +34 -66
  52. package/dist/hermes/runtime/hermes-runtime-cron.js.map +1 -1
  53. package/dist/hermes/runtime/hermes-runtime-history-completion.d.ts +8 -1
  54. package/dist/hermes/runtime/hermes-runtime-history-completion.js +112 -41
  55. package/dist/hermes/runtime/hermes-runtime-history-completion.js.map +1 -1
  56. package/dist/hermes/runtime/hermes-runtime-history.js +92 -17
  57. package/dist/hermes/runtime/hermes-runtime-history.js.map +1 -1
  58. package/dist/hermes/runtime/hermes-runtime-models.js +4 -1
  59. package/dist/hermes/runtime/hermes-runtime-models.js.map +1 -1
  60. package/dist/hermes/runtime/hermes-runtime-process.js +4 -3
  61. package/dist/hermes/runtime/hermes-runtime-process.js.map +1 -1
  62. package/dist/hermes/runtime/hermes-runtime-state-db.d.ts +6 -0
  63. package/dist/hermes/runtime/hermes-runtime-state-db.js +20 -1
  64. package/dist/hermes/runtime/hermes-runtime-state-db.js.map +1 -1
  65. package/dist/hermes/runtime/hermes-runtime-tool-log-watcher.js +12 -20
  66. package/dist/hermes/runtime/hermes-runtime-tool-log-watcher.js.map +1 -1
  67. package/dist/index.js +16 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/openclaw/config/provider-config.js +4 -1
  70. package/dist/openclaw/config/provider-config.js.map +1 -1
  71. package/dist/openclaw/relay/agent-identity-helpers.d.ts +9 -0
  72. package/dist/openclaw/relay/agent-identity-helpers.js +118 -0
  73. package/dist/openclaw/relay/agent-identity-helpers.js.map +1 -0
  74. package/dist/openclaw/relay/assistant-media-sidecar.d.ts +28 -1
  75. package/dist/openclaw/relay/assistant-media-sidecar.js +270 -10
  76. package/dist/openclaw/relay/assistant-media-sidecar.js.map +1 -1
  77. package/dist/openclaw/relay/chat-history.d.ts +37 -2
  78. package/dist/openclaw/relay/chat-history.js +570 -49
  79. package/dist/openclaw/relay/chat-history.js.map +1 -1
  80. package/dist/openclaw/relay/chat-run-identity.js +3 -1
  81. package/dist/openclaw/relay/chat-run-identity.js.map +1 -1
  82. package/dist/openclaw/relay/gateway-history-projection.d.ts +4 -0
  83. package/dist/openclaw/relay/gateway-history-projection.js +67 -0
  84. package/dist/openclaw/relay/gateway-history-projection.js.map +1 -0
  85. package/dist/openclaw/relay/openclaw-active-run-state.d.ts +67 -0
  86. package/dist/openclaw/relay/openclaw-active-run-state.js +283 -0
  87. package/dist/openclaw/relay/openclaw-active-run-state.js.map +1 -0
  88. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.d.ts +12 -0
  89. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.js +48 -0
  90. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.js.map +1 -0
  91. package/dist/openclaw/relay/openclaw-source-commit-watcher.d.ts +22 -0
  92. package/dist/openclaw/relay/openclaw-source-commit-watcher.js +64 -0
  93. package/dist/openclaw/relay/openclaw-source-commit-watcher.js.map +1 -0
  94. package/dist/openclaw/relay/outgoing-media-relay.d.ts +35 -1
  95. package/dist/openclaw/relay/outgoing-media-relay.js +541 -123
  96. package/dist/openclaw/relay/outgoing-media-relay.js.map +1 -1
  97. package/dist/openclaw/relay/relay-manager-history-timing.d.ts +1 -4
  98. package/dist/openclaw/relay/relay-manager-history-timing.js +4 -4
  99. package/dist/openclaw/relay/relay-manager-history-timing.js.map +1 -1
  100. package/dist/openclaw/relay/relay-manager-payload-helpers.d.ts +28 -1
  101. package/dist/openclaw/relay/relay-manager-payload-helpers.js +219 -11
  102. package/dist/openclaw/relay/relay-manager-payload-helpers.js.map +1 -1
  103. package/dist/openclaw/relay/session-context.d.ts +15 -0
  104. package/dist/openclaw/relay/session-context.js +48 -4
  105. package/dist/openclaw/relay/session-context.js.map +1 -1
  106. package/dist/openclaw/relay/source-commit-projection.d.ts +25 -0
  107. package/dist/openclaw/relay/source-commit-projection.js +140 -0
  108. package/dist/openclaw/relay/source-commit-projection.js.map +1 -0
  109. package/dist/openclaw/relay/transcript-dedupe.js +11 -42
  110. package/dist/openclaw/relay/transcript-dedupe.js.map +1 -1
  111. package/dist/openclaw/relay-manager.js +329 -131
  112. package/dist/openclaw/relay-manager.js.map +1 -1
  113. package/dist/openclaw/runtime/local-runtime.js +4 -2
  114. package/dist/openclaw/runtime/local-runtime.js.map +1 -1
  115. package/package.json +1 -1
  116. package/dist/openclaw/session-store.d.ts +0 -6
  117. package/dist/openclaw/session-store.js +0 -231
  118. package/dist/openclaw/session-store.js.map +0 -1
@@ -4,25 +4,36 @@ 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,72 +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 && attempt < CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS) {
163
- scheduleChatHistoryFallback(providerRunId, context, attempt + 1);
164
- return;
165
- }
166
- if (!outcome) {
167
- chatFallbacks.delete(providerRunId);
168
- chatRunContexts.delete(providerRunId);
169
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
170
- return;
171
- }
172
- clearChatFallback(providerRunId);
173
- chatRunContexts.delete(providerRunId);
174
- if (outcome.kind === "final") {
175
- openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
176
- const basePayload = buildMobileAssistantFinalPayload({
177
- run: { runId: context.canonicalRunId, sessionKey: context.sessionKey },
178
- text: outcome.text,
179
- includeTimelineEvents: true,
180
- });
181
- await publishAndSendGatewayEvent("chat", buildFinalPayloadFromHistoryOutcome(basePayload, outcome), true, context.promptText);
182
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
183
- return;
184
- }
185
- openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
186
- await publishAndSendGatewayEvent("chat", buildMobileAssistantErrorPayload({
187
- run: { runId: context.canonicalRunId, sessionKey: context.sessionKey },
188
- errorMessage: outcome.errorMessage,
189
- includeTimelineEvents: true,
190
- }), true);
191
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
192
- })
193
- .catch((err) => {
194
- if (attempt < CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS) {
195
- scheduleChatHistoryFallback(providerRunId, context, attempt + 1);
196
- return;
197
- }
198
- console.warn(`[relay] chat history fallback failed runId=${providerRunId}: ${String(err)}`);
199
- chatFallbacks.delete(providerRunId);
200
- chatRunContexts.delete(providerRunId);
201
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
202
- });
203
- }, attempt === 0 ? CHAT_HISTORY_FALLBACK_INITIAL_DELAY_MS : CHAT_HISTORY_FALLBACK_RETRY_DELAY_MS);
204
- timer.unref?.();
205
- chatFallbacks.set(providerRunId, timer);
169
+ return normalized || rawSessionKey.trim();
206
170
  };
207
171
  const ensureSessionMessagesSubscribed = async (sessionKey) => {
208
- const normalizedSessionKey = canonicalizeSessionKey(sessionKey, sessionDefaults);
209
- if (typeof normalizedSessionKey !== "string" || normalizedSessionKey.trim().length === 0) {
172
+ const key = explicitOpenClawSessionKey(sessionKey);
173
+ if (!key) {
210
174
  throw new Error("cannot subscribe to an empty OpenClaw session key");
211
175
  }
212
- const key = normalizedSessionKey.trim();
213
176
  if (subscribedSessionMessageKeys.has(key)) {
214
177
  return;
215
178
  }
@@ -248,6 +211,7 @@ export async function runRelayManager(opts) {
248
211
  sessionDefaults = nextDefaults;
249
212
  }
250
213
  await ensureSessionMessagesSubscribed(sessionDefaults.mainSessionKey);
214
+ ensureSourceCommitWatcher(sessionDefaults.mainSessionKey);
251
215
  const sessionsPayload = await gatewayClient.request("sessions.list", {
252
216
  limit: 100,
253
217
  includeGlobal: true,
@@ -263,6 +227,11 @@ export async function runRelayManager(opts) {
263
227
  : undefined;
264
228
  if (typeof sessionKey !== "string" || !sessionKey.trim())
265
229
  continue;
230
+ // Cron jobs can target an agent session other than the default one.
231
+ // Subscribe each known session so their finished replies travel through
232
+ // the same ClawConnect → Relay realtime path as an interactive chat.
233
+ await ensureSessionMessagesSubscribed(sessionKey);
234
+ ensureSourceCommitWatcher(sessionKey);
266
235
  const snapshot = contextUsageSnapshotFromSessionsList(sessionsPayload, sessionKey, sessionDefaults);
267
236
  if (!snapshot)
268
237
  continue;
@@ -334,6 +303,8 @@ export async function runRelayManager(opts) {
334
303
  senderDisplayName: "OpenClaw",
335
304
  cache: outgoingMediaUploadCache,
336
305
  userMessage,
306
+ waitForOutgoingMediaRecord: true,
307
+ sessionDefaults,
337
308
  });
338
309
  }
339
310
  catch (error) {
@@ -369,14 +340,14 @@ export async function runRelayManager(opts) {
369
340
  console.warn(`[relay] assistant snapshot publish failed key=${key}: ${String(error)}`);
370
341
  },
371
342
  });
372
- const assistantSnapshotKey = (runId, sessionKey) => {
343
+ const assistantSnapshotKey = (runId, sessionKey, messageId) => {
373
344
  if (!runId?.trim() || !sessionKey?.trim()) {
374
345
  return undefined;
375
346
  }
376
347
  return buildAssistantStreamSnapshotKey({
377
348
  sessionKey,
378
349
  runId,
379
- messageId: `assistant-${runId}`,
350
+ ...(messageId ? { messageId } : { messageId: `assistant-${runId}` }),
380
351
  });
381
352
  };
382
353
  const runAfterAssistantSnapshot = (key, close, operation, releaseClosed = false) => {
@@ -417,7 +388,7 @@ export async function runRelayManager(opts) {
417
388
  const sessionKey = typeof record.sessionKey === "string" && record.sessionKey.trim()
418
389
  ? record.sessionKey.trim()
419
390
  : runContext?.sessionKey;
420
- return assistantSnapshotKey(runId, sessionKey);
391
+ return assistantSnapshotKey(runId, sessionKey, runId ? assistantReplyMessageId(payload, runId) : undefined);
421
392
  };
422
393
  async function relayOutgoingMediaForResponse(method, payload) {
423
394
  if (method !== "chat.history") {
@@ -429,12 +400,23 @@ export async function runRelayManager(opts) {
429
400
  gatewayId: opts.gatewayId,
430
401
  senderDisplayName: "OpenClaw",
431
402
  cache: outgoingMediaUploadCache,
403
+ sessionDefaults,
432
404
  });
433
405
  }
434
406
  async function requestChatHistoryFromClawConnect(params) {
407
+ // Relay-owned history reads opt into the v3 projection contract. The
408
+ // public legacy helper remains v2-compatible for older callers during
409
+ // rollout; every confirmed row consumed by this manager is v3.
410
+ const projectionParams = params && typeof params === "object" && !Array.isArray(params)
411
+ ? {
412
+ ...params,
413
+ projectionVersion: 3,
414
+ projectionGatewayId: opts.gatewayId,
415
+ }
416
+ : { projectionVersion: 3, projectionGatewayId: opts.gatewayId };
435
417
  try {
436
- await chatSendDedupe.dedupePendingForSession(params);
437
- const transcriptHistory = await readOpenClawTranscriptChatHistory(params, sessionDefaults);
418
+ await chatSendDedupe.dedupePendingForSession(projectionParams);
419
+ const transcriptHistory = await readOpenClawTranscriptChatHistory(projectionParams, sessionDefaults);
438
420
  if (transcriptHistory) {
439
421
  return transcriptHistory;
440
422
  }
@@ -443,7 +425,7 @@ export async function runRelayManager(opts) {
443
425
  console.warn(`[relay] transcript chat.history unavailable: ${String(err)}`);
444
426
  }
445
427
  if (hasHistoryCursor(params)) {
446
- return buildEmptyHistoryPage(params, sessionDefaults);
428
+ return buildEmptyHistoryPage(projectionParams, sessionDefaults);
447
429
  }
448
430
  if (!gatewayClient) {
449
431
  throw new Error("gateway not connected");
@@ -452,8 +434,56 @@ export async function runRelayManager(opts) {
452
434
  const history = await gatewayClient.request("chat.history", gatewayHistoryParams);
453
435
  return canonicalizeOpenClawGatewayHistoryResponse(history, {
454
436
  sessionKey: String(gatewayHistoryParams.sessionKey),
437
+ projectionVersion: 3,
438
+ projectionGatewayId: opts.gatewayId,
439
+ sessionDefaults,
455
440
  });
456
441
  }
442
+ const ensureSourceCommitWatcher = (sessionKey) => {
443
+ const normalizedSessionKey = explicitOpenClawSessionKey(sessionKey);
444
+ if (!normalizedSessionKey)
445
+ return;
446
+ const key = `${opts.gatewayId}\u0000${normalizedSessionKey}`;
447
+ if (sourceCommitWatchers.has(key))
448
+ return;
449
+ const agentId = openClawAgentIdFromSessionKey(normalizedSessionKey);
450
+ const databasePath = join(resolveOpenClawStateDir(), "agents", agentId, "agent", "openclaw-agent.sqlite");
451
+ const watcher = watchOpenClawSourceCommit({
452
+ databasePath,
453
+ readCursor: () => readOpenClawSourceCommitCursor({
454
+ sessionKey: normalizedSessionKey,
455
+ projectionVersion: 3,
456
+ projectionGatewayId: opts.gatewayId,
457
+ }, sessionDefaults),
458
+ onCommit: async (sourceCommit, previousCommittedThroughSeq) => {
459
+ await projectSourceCommitHistoryPages({
460
+ sourceCommit,
461
+ previousCommittedThroughSeq,
462
+ readPage: (cursorSeq) => withTimeout(requestChatHistoryFromClawConnect({
463
+ sessionKey: normalizedSessionKey,
464
+ limit: 200,
465
+ cursor: `seq:${cursorSeq}`,
466
+ direction: "newer",
467
+ }), CHAT_HISTORY_FETCH_TIMEOUT_MS, "source commit history reconciliation"),
468
+ publish: async ({ sourceCommit: pageSourceCommit, events }) => {
469
+ const deliveryResult = await publishAndSendGatewayEvent("chat", {
470
+ state: "source_commit",
471
+ sessionKey: normalizedSessionKey,
472
+ sourceCommit: pageSourceCommit,
473
+ timelineEvents: events,
474
+ }, true, undefined, true);
475
+ if (deliveryResult?.status === "retryable") {
476
+ throw deliveryResult.error;
477
+ }
478
+ },
479
+ });
480
+ },
481
+ onError: (error) => {
482
+ console.warn(`[relay] source commit reconciliation failed session=${normalizedSessionKey}: ${String(error)}`);
483
+ },
484
+ });
485
+ sourceCommitWatchers.set(key, watcher);
486
+ };
457
487
  relayWs.on("open", () => {
458
488
  console.log(`Connected to relay server (gatewayId=${opts.gatewayId})`);
459
489
  opts.onConnected?.();
@@ -495,8 +525,28 @@ export async function runRelayManager(opts) {
495
525
  });
496
526
  },
497
527
  onEvent: (event, payload) => {
528
+ if (event === "session.message") {
529
+ // OpenClaw emits this subscription event after the transcript
530
+ // append has committed. It is the primary wake-up for source
531
+ // reconciliation; SQLite notifications and the low-frequency
532
+ // anti-entropy scan remain recovery paths for dropped events.
533
+ const record = payload && typeof payload === "object" && !Array.isArray(payload)
534
+ ? payload
535
+ : undefined;
536
+ const rawSessionKey = typeof record?.sessionKey === "string"
537
+ ? record.sessionKey.trim()
538
+ : "";
539
+ if (rawSessionKey) {
540
+ const sessionKey = canonicalizeSessionKey(rawSessionKey, sessionDefaults);
541
+ if (typeof sessionKey === "string" && sessionKey.trim()) {
542
+ ensureSourceCommitWatcher(sessionKey);
543
+ sourceCommitWatchers.get(`${opts.gatewayId}\u0000${sessionKey.trim()}`)?.notify();
544
+ }
545
+ }
546
+ return;
547
+ }
498
548
  const normalizedPayload = event === "chat"
499
- ? canonicalizeOpenClawAssistantMediaSidecarPayload(normalizeChatEventPayload(payload))
549
+ ? materializeOpenClawDisplayContentPayload(canonicalizeOpenClawAssistantMediaSidecarPayload(normalizeChatEventPayload(payload)))
500
550
  : payload;
501
551
  const shouldPublishOffice = event === "chat" || event === "agent" || event === "context_usage";
502
552
  if (event === "chat") {
@@ -507,10 +557,16 @@ export async function runRelayManager(opts) {
507
557
  const isTerminalState = state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted";
508
558
  const sessionKey = typeof p?.sessionKey === "string" && p.sessionKey.trim().length > 0 ? p.sessionKey.trim() : undefined;
509
559
  const explicitCanonicalRunId = resolveExplicitMobileRunIdFromChatPayload(normalizedPayload);
560
+ const currentText = extractChatText(normalizedPayload);
561
+ const role = extractChatRole(normalizedPayload);
510
562
  let runContext = providerRunId
511
563
  ? chatRunContexts.get(providerRunId) ?? openClawChatRunIdentities.resolve(opts.gatewayId, providerRunId)
512
564
  : undefined;
513
- if (!runContext && providerRunId && sessionKey) {
565
+ // Event-only registration is safe only after the provider proves
566
+ // assistant provenance. A roleless terminal may contain a user
567
+ // echo or an unrelated provider event and must not create a run
568
+ // context from its text/session fields.
569
+ if (!runContext && providerRunId && sessionKey && role === "assistant") {
514
570
  runContext = {
515
571
  gatewayId: opts.gatewayId,
516
572
  providerRunId,
@@ -518,19 +574,37 @@ export async function runRelayManager(opts) {
518
574
  sessionKey,
519
575
  };
520
576
  openClawChatRunIdentities.ensure(runContext);
521
- chatRunContexts.set(providerRunId, runContext);
522
- }
523
- if (providerRunId && isStreamingState && openClawChatRunIdentities.isTerminal(opts.gatewayId, providerRunId)) {
524
- return;
577
+ setChatRunContext(providerRunId, runContext);
525
578
  }
526
579
  if (providerRunId && isTerminalState) {
527
580
  openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
528
581
  }
529
582
  const canonicalRunId = runContext?.canonicalRunId ?? explicitCanonicalRunId ?? providerRunId;
530
583
  const resolvedSessionKey = sessionKey ?? runContext?.sessionKey;
531
- const currentText = extractChatText(normalizedPayload);
532
- const role = extractChatRole(normalizedPayload);
533
- const runSnapshotKey = assistantSnapshotKey(canonicalRunId, resolvedSessionKey);
584
+ if (isTerminalState && runContext && canonicalRunId && resolvedSessionKey) {
585
+ void markOpenClawActiveRunTerminal({
586
+ gatewayId: opts.gatewayId,
587
+ sessionKey: resolvedSessionKey,
588
+ sourceRunId: canonicalRunId,
589
+ }).catch((error) => {
590
+ console.warn(`[relay] failed to mark OpenClaw run terminal: ${String(error)}`);
591
+ });
592
+ }
593
+ const replyMessageId = role === "assistant" && canonicalRunId
594
+ ? assistantReplyMessageId(normalizedPayload, canonicalRunId)
595
+ : undefined;
596
+ const chatLineage = extractChatLineage(normalizedPayload);
597
+ const runSnapshotKey = assistantSnapshotKey(canonicalRunId, resolvedSessionKey, replyMessageId);
598
+ // OpenClaw's message tool may emit several independent assistant
599
+ // replies after a provider-level terminal snapshot. Keep filtering
600
+ // late stream deltas for the run-level text reply, but let a reply
601
+ // with its own upstream message/tool/media identity through.
602
+ if (providerRunId
603
+ && isStreamingState
604
+ && openClawChatRunIdentities.isTerminal(opts.gatewayId, providerRunId)
605
+ && !replyMessageId) {
606
+ return;
607
+ }
534
608
  if (isOpenClawHeartbeatText(currentText) || (runContext?.promptText && isOpenClawHeartbeatText(runContext.promptText))) {
535
609
  if (providerRunId && (state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
536
610
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
@@ -541,6 +615,51 @@ export async function runRelayManager(opts) {
541
615
  }
542
616
  return;
543
617
  }
618
+ // A roleless error/abort is still a terminal lifecycle signal when
619
+ // it belongs to a previously registered provider run. Its identity
620
+ // comes from that run context; never infer it from the payload
621
+ // text. A roleless empty final is lifecycle-only and leaves source
622
+ // rows to the transcript/source-commit watcher. Text-bearing
623
+ // roleless deltas/finals remain rejected user/source echoes.
624
+ if (role !== "assistant") {
625
+ if (!runContext || !providerRunId || !canonicalRunId || !resolvedSessionKey) {
626
+ return;
627
+ }
628
+ if (state === "error" || state === "failed" || state === "fail" || state === "aborted") {
629
+ chatSendDedupe.scheduleForRun(providerRunId, 100);
630
+ chatRunContexts.delete(providerRunId);
631
+ runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
632
+ await publishAndSendGatewayEvent(event, mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantErrorPayload({
633
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
634
+ errorMessage: extractChatErrorMessage(normalizedPayload),
635
+ includeTimelineEvents: true,
636
+ })), shouldPublishOffice, runContext?.promptText);
637
+ openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
638
+ }, true);
639
+ return;
640
+ }
641
+ if (state === "final" && !currentText) {
642
+ chatRunContexts.delete(providerRunId);
643
+ const lifecyclePayload = normalizedPayload && typeof normalizedPayload === "object" && !Array.isArray(normalizedPayload)
644
+ ? (() => {
645
+ const payload = { ...normalizedPayload };
646
+ delete payload.message;
647
+ delete payload.content;
648
+ delete payload.text;
649
+ delete payload.delta;
650
+ return payload;
651
+ })()
652
+ : normalizedPayload;
653
+ runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
654
+ await publishAndSendGatewayEvent(event, mergeCanonicalChatPayload(lifecyclePayload, buildMobileAssistantCompletedPayload({
655
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
656
+ includeTimelineEvents: true,
657
+ })), shouldPublishOffice, runContext?.promptText);
658
+ openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
659
+ }, true);
660
+ }
661
+ return;
662
+ }
544
663
  let realtimePayload = providerRunId && canonicalRunId && canonicalRunId !== providerRunId
545
664
  ? {
546
665
  ...normalizedPayload,
@@ -552,9 +671,6 @@ export async function runRelayManager(opts) {
552
671
  if (state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted") {
553
672
  chatSendDedupe.scheduleForRun(providerRunId, 100);
554
673
  }
555
- if (role === "assistant" && (state === "delta" || state === "final" || state === "error" || state === "failed" || state === "fail")) {
556
- clearChatFallback(providerRunId);
557
- }
558
674
  if (state === "delta" || state === "streaming" || state === "in_progress") {
559
675
  const previousText = openClawChatRunIdentities.accumulatedText(opts.gatewayId, providerRunId);
560
676
  const bufferedText = appendUniqueSuffix(previousText, currentText);
@@ -566,6 +682,7 @@ export async function runRelayManager(opts) {
566
682
  seq: resolveChatPayloadSeq(normalizedPayload),
567
683
  timestampMs: resolveChatPayloadTimestamp(normalizedPayload),
568
684
  delta: bufferedText,
685
+ ...chatLineage,
569
686
  includeTimelineEvents: true,
570
687
  }))
571
688
  : withMessageText(normalizedPayload, bufferedText);
@@ -593,27 +710,79 @@ export async function runRelayManager(opts) {
593
710
  });
594
711
  return;
595
712
  }
596
- if (state === "final" && resolvedSessionKey) {
713
+ if (state === "final" && resolvedSessionKey && role === "assistant") {
597
714
  scheduleContextUsageRefresh(resolvedSessionKey, 450);
598
715
  const bufferedText = providerRunId
599
716
  ? openClawChatRunIdentities.accumulatedText(opts.gatewayId, providerRunId)
600
717
  : "";
601
718
  const resolvedText = appendUniqueSuffix(bufferedText, currentText);
602
- if (resolvedText.trim()) {
603
- const outgoingPayload = providerRunId
604
- ? mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
605
- run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
606
- text: resolvedText,
607
- contentBlocks: nonTextContentBlocks(normalizedPayload),
608
- includeTimelineEvents: true,
609
- ...mobileAssistantUsageFromPayload(normalizedPayload),
610
- }))
611
- : withMessageText(normalizedPayload, resolvedText);
612
- runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
719
+ if (resolvedText.trim() && !isOpenClawMediaDisplayPlaceholder(resolvedText)) {
720
+ // A provider-level terminal without a reply identity stays
721
+ // open for later text-only delivery terminals. Each such
722
+ // terminal re-reads the current turn history and reconciles
723
+ // the canonical media snapshot deterministically.
724
+ runAfterAssistantSnapshot(runSnapshotKey, Boolean(replyMessageId), async () => {
725
+ let outgoingPayload;
726
+ const directContentBlocks = nonTextContentBlocks(normalizedPayload);
727
+ if (providerRunId && runContext) {
728
+ try {
729
+ const history = await withTimeout(requestChatHistoryFromClawConnect({ sessionKey: resolvedSessionKey, limit: 10 }), CHAT_HISTORY_MEDIA_ENRICHMENT_TIMEOUT_MS, "chat.history media enrichment");
730
+ let outcome = extractHistoryOutcome(history, runContext, currentText);
731
+ // A message-tool delivery can reach the gateway as a
732
+ // text-only terminal before OpenClaw commits its
733
+ // display-media projection. Always inspect the whole
734
+ // matched turn, not just the selected outcome row, so
735
+ // either a direct image or a later delivery row can
736
+ // keep the terminal media-complete.
737
+ let historyContentBlocks = outcome?.kind === "final"
738
+ ? hasCanonicalProjectionV3Snapshot(history)
739
+ ? mergeNonTextContentBlocks(nonTextContentBlocksFromHistory(outcome.message), extractExplicitParentMediaContent(history, outcome.message))
740
+ : extractHistoryMediaContent(history, runContext)
741
+ : [];
742
+ if (outcome?.kind === "final" && historyContentBlocks.length > 0) {
743
+ // A text terminal is not guaranteed to carry the
744
+ // message-tool identity. Recover it from the stable
745
+ // transcript delivery row before building timeline
746
+ // events; otherwise every sidecar in the run is
747
+ // projected onto assistant-${runId}.
748
+ const historyReplyMessageId = assistantHistoryReplyMessageId(outcome.message, canonicalRunId);
749
+ const mergedContentBlocks = mergeNonTextContentBlocks(directContentBlocks, historyContentBlocks);
750
+ const basePayload = mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
751
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
752
+ text: resolvedText,
753
+ contentBlocks: mergedContentBlocks,
754
+ ...chatLineage,
755
+ ...((replyMessageId ?? historyReplyMessageId)
756
+ ? { messageId: replyMessageId ?? historyReplyMessageId }
757
+ : {}),
758
+ includeTimelineEvents: true,
759
+ ...mobileAssistantUsageFromPayload(normalizedPayload),
760
+ }));
761
+ outgoingPayload = buildFinalPayloadFromHistoryOutcome(basePayload, outcome, [{ type: "text", text: outcome.text }, ...mergedContentBlocks]);
762
+ }
763
+ }
764
+ catch (error) {
765
+ // Media enrichment is opportunistic. The final text event
766
+ // must still reach the client when history is unavailable.
767
+ console.warn(`[relay] terminal media history enrichment skipped: ${String(error)}`);
768
+ }
769
+ }
770
+ if (outgoingPayload === undefined) {
771
+ outgoingPayload = providerRunId
772
+ ? mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
773
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
774
+ text: resolvedText,
775
+ contentBlocks: directContentBlocks,
776
+ ...chatLineage,
777
+ ...(replyMessageId ? { messageId: replyMessageId } : {}),
778
+ includeTimelineEvents: true,
779
+ ...mobileAssistantUsageFromPayload(normalizedPayload),
780
+ }))
781
+ : withMessageText(normalizedPayload, resolvedText);
782
+ }
613
783
  await publishAndSendGatewayEvent(event, outgoingPayload, shouldPublishOffice, runContext?.promptText);
614
784
  if (providerRunId) {
615
785
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
616
- chatRunContexts.delete(providerRunId);
617
786
  }
618
787
  }, Boolean(providerRunId));
619
788
  return;
@@ -621,30 +790,39 @@ export async function runRelayManager(opts) {
621
790
  const fetchHistory = () => requestChatHistoryFromClawConnect({ sessionKey: resolvedSessionKey, limit: 10 });
622
791
  const publishHistoryTerminal = () => withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history")
623
792
  .then(async (history) => {
624
- let outcome = runContext ? extractHistoryOutcome(history, runContext) : null;
625
- // Retry once if OpenClaw has emitted final before the transcript is committed.
626
- if (!outcome) {
627
- await new Promise((resolve) => setTimeout(resolve, CHAT_HISTORY_FINAL_RETRY_DELAY_MS));
628
- const retryHistory = await withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history retry");
629
- outcome = runContext ? extractHistoryOutcome(retryHistory, runContext) : null;
630
- }
793
+ // The terminal event is the only completion signal. Do not
794
+ // turn transcript visibility into a wall-clock race: a
795
+ // later provider terminal/history page will re-enter this
796
+ // path and reconcile the newly committed source rows.
797
+ const outcome = runContext ? extractHistoryOutcome(history, runContext, currentText) : null;
631
798
  if (providerRunId && outcome) {
632
- chatRunContexts.delete(providerRunId);
633
799
  }
634
800
  if (outcome?.kind === "final") {
801
+ const historyReplyMessageId = providerRunId
802
+ ? assistantHistoryReplyMessageId(outcome.message, canonicalRunId)
803
+ : undefined;
635
804
  const basePayload = providerRunId
636
805
  ? buildMobileAssistantFinalPayload({
637
806
  run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
638
807
  text: outcome.text,
639
- contentBlocks: nonTextContentBlocksFromHistory(outcome.message),
808
+ ...chatLineage,
809
+ contentBlocks: runContext
810
+ ? hasCanonicalProjectionV3Snapshot(history)
811
+ ? mergeNonTextContentBlocks(nonTextContentBlocksFromHistory(outcome.message), extractExplicitParentMediaContent(history, outcome.message))
812
+ : extractHistoryMediaContent(history, runContext)
813
+ : nonTextContentBlocksFromHistory(outcome.message),
814
+ ...((replyMessageId ?? historyReplyMessageId)
815
+ ? { messageId: replyMessageId ?? historyReplyMessageId }
816
+ : {}),
640
817
  includeTimelineEvents: true,
641
818
  ...mobileAssistantUsageFromPayload(normalizedPayload),
642
819
  })
643
820
  : normalizedPayload;
644
821
  const outgoingPayload = buildFinalPayloadFromHistoryOutcome(basePayload, outcome);
645
822
  await publishAndSendGatewayEvent(event, outgoingPayload, shouldPublishOffice, runContext?.promptText);
646
- if (providerRunId)
823
+ if (providerRunId) {
647
824
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
825
+ }
648
826
  return;
649
827
  }
650
828
  if (outcome?.kind === "error") {
@@ -664,21 +842,13 @@ export async function runRelayManager(opts) {
664
842
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
665
843
  return;
666
844
  }
667
- if (providerRunId && runContext) {
668
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
669
- return;
670
- }
671
845
  await publishAndSendGatewayEvent(event, normalizedPayload, shouldPublishOffice);
672
846
  })
673
847
  .catch((err) => {
674
848
  console.error(`[relay] chat.history fetch failed: ${err}`);
675
- if (providerRunId && runContext) {
676
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
677
- return;
678
- }
679
849
  void publishAndSendGatewayEvent(event, normalizedPayload, shouldPublishOffice);
680
850
  });
681
- runAfterAssistantSnapshot(runSnapshotKey, true, publishHistoryTerminal, Boolean(providerRunId));
851
+ runAfterAssistantSnapshot(runSnapshotKey, Boolean(replyMessageId), publishHistoryTerminal, Boolean(providerRunId));
682
852
  return;
683
853
  }
684
854
  if (providerRunId && (state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
@@ -851,6 +1021,22 @@ export async function runRelayManager(opts) {
851
1021
  // thinking/tool/text event cannot win the race with this request.
852
1022
  await ensureSessionMessagesSubscribed(commandSessionKey);
853
1023
  }
1024
+ const sourceRunIdForState = commandMethod === "chat.send"
1025
+ ? normalizeOpenClawMobileRunId(paramsRecord?.idempotencyKey)
1026
+ ?? normalizeOpenClawMobileRunId(voiceInputRun?.runId)
1027
+ ?? requestId
1028
+ : normalizeOpenClawMobileRunId(paramsRecord?.runId)
1029
+ ?? normalizeOpenClawMobileRunId(paramsRecord?.idempotencyKey)
1030
+ ?? requestId;
1031
+ if ((commandMethod === "chat.send" || commandMethod === "agent") && sourceRunIdForState) {
1032
+ await recordOpenClawActiveRun({
1033
+ gatewayId: opts.gatewayId,
1034
+ sessionKey: typeof paramsRecord?.sessionKey === "string" && paramsRecord.sessionKey.trim()
1035
+ ? paramsRecord.sessionKey.trim()
1036
+ : sessionDefaults.mainSessionKey,
1037
+ sourceRunId: sourceRunIdForState,
1038
+ });
1039
+ }
854
1040
  const registerIdentity = (result) => {
855
1041
  const resultRecord = result && typeof result === "object" && !Array.isArray(result)
856
1042
  ? result
@@ -878,11 +1064,19 @@ export async function runRelayManager(opts) {
878
1064
  : undefined,
879
1065
  };
880
1066
  openClawChatRunIdentities.register(identity);
881
- chatRunContexts.set(identity.providerRunId, identity);
1067
+ setChatRunContext(identity.providerRunId, identity);
1068
+ void updateOpenClawActiveRunProviderId({
1069
+ gatewayId: opts.gatewayId,
1070
+ sessionKey: identity.sessionKey,
1071
+ sourceRunId: identity.canonicalRunId,
1072
+ providerRunId: identity.providerRunId,
1073
+ }).catch((error) => {
1074
+ console.warn(`[relay] failed to update OpenClaw active run provider id: ${String(error)}`);
1075
+ });
882
1076
  return identity;
883
1077
  };
884
1078
  let identity;
885
- const result = commandMethod === "chat.history"
1079
+ let result = commandMethod === "chat.history"
886
1080
  ? await requestChatHistoryFromClawConnect(params)
887
1081
  : await gatewayClient.request(commandMethod, params, {
888
1082
  // response 帧内同步登记,保证紧随其后的首个 delta 已能解析 canonical 身份。
@@ -891,6 +1085,9 @@ export async function runRelayManager(opts) {
891
1085
  },
892
1086
  });
893
1087
  identity ??= registerIdentity(result);
1088
+ if (commandMethod === "agents.list" && gatewayClient) {
1089
+ result = await enrichAgentsListWithIdentities(result, gatewayClient);
1090
+ }
894
1091
  return { params, result, identity };
895
1092
  };
896
1093
  const commandExecution = chatSendIdempotencyRequest
@@ -920,9 +1117,7 @@ export async function runRelayManager(opts) {
920
1117
  const sessionKey = typeof paramsRecord?.sessionKey === "string" && paramsRecord.sessionKey.trim().length > 0
921
1118
  ? paramsRecord.sessionKey.trim()
922
1119
  : sessionDefaults.mainSessionKey;
923
- if (providerRunId && identity) {
924
- scheduleChatHistoryFallback(providerRunId, identity);
925
- }
1120
+ ensureSourceCommitWatcher(sessionKey);
926
1121
  if (commandMethod === "chat.send" && chatSendDedupeRequest) {
927
1122
  chatSendDedupe.register(chatSendDedupeRequest, providerRunId);
928
1123
  }
@@ -974,10 +1169,9 @@ export async function runRelayManager(opts) {
974
1169
  opts.onDisconnected?.();
975
1170
  gatewayClient?.stop();
976
1171
  gatewayClient = null;
977
- for (const timer of chatFallbacks.values()) {
978
- clearTimeout(timer);
979
- }
980
- chatFallbacks.clear();
1172
+ for (const watcher of sourceCommitWatchers.values())
1173
+ watcher.close();
1174
+ sourceCommitWatchers.clear();
981
1175
  chatSendDedupe.clearAll();
982
1176
  for (const timer of contextUsageRefreshes.values()) {
983
1177
  clearTimeout(timer);
@@ -1006,6 +1200,10 @@ export async function runRelayManager(opts) {
1006
1200
  upper.startsWith("HEARTBEAT_OK") ||
1007
1201
  upper.startsWith("HEARTBEAT OK"));
1008
1202
  }
1203
+ function isOpenClawMediaDisplayPlaceholder(text) {
1204
+ const normalized = text?.replace(/\r/g, "").trim() ?? "";
1205
+ return /^(?:Media reply could not be displayed\.\s*)+$/i.test(normalized);
1206
+ }
1009
1207
  function hasCanonicalTerminalTimeline(payload) {
1010
1208
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
1011
1209
  return false;