openclaw-lark-multi-agent 1.4.4 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,21 @@ import { randomUUID } from "crypto";
3
3
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
4
  import { basename, extname, join, resolve, sep } from "path";
5
5
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
6
- export const GATEWAY_PROTOCOL_MIN = 3;
6
+ export const GATEWAY_PROTOCOL_MIN = 4;
7
7
  export const GATEWAY_PROTOCOL_MAX = 4;
8
+ const REQUIRED_GATEWAY_METHODS = [
9
+ "agent.wait",
10
+ "chat.abort",
11
+ "chat.send",
12
+ "sessions.compact",
13
+ "sessions.create",
14
+ "sessions.delete",
15
+ "sessions.describe",
16
+ "sessions.messages.subscribe",
17
+ "sessions.patch",
18
+ "sessions.reset",
19
+ ];
20
+ const REQUIRED_GATEWAY_EVENTS = ["agent", "chat", "session.message"];
8
21
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
9
22
  const CONTEXT_SYNC_DIR = join(getDataDir(), "context-sync");
10
23
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
@@ -28,11 +41,14 @@ export class OpenClawClient {
28
41
  /** Callbacks for tool events (verbose mode) */
29
42
  toolEventCallbacks = new Map();
30
43
  lastToolNamesByItemId = new Map();
44
+ /** Deduplicates 2026.8 canonical `agent.stream=tool` events against legacy
45
+ * `stream=item` mirrors when a runtime emits both representations. */
46
+ recentToolEventKeys = new Map();
31
47
  sessionMessageCallbacks = new Map();
32
48
  progressCallbacks = new Map();
33
- /** Per-session steered messages awaiting consumption confirmation. Maps the
34
- * normalized text actually sent (possibly wrapped with an insertion prefix)
35
- * to the ORIGINAL display text shown on the live-status card. */
49
+ /** Per-session steered messages awaiting consumption confirmation. Each
50
+ * accepted message is retained independently so identical/overlapping text
51
+ * cannot overwrite another correlation. */
36
52
  pendingSteerTexts = new Map();
37
53
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
38
54
  verboseTranscriptSessions = new Set();
@@ -45,9 +61,11 @@ export class OpenClawClient {
45
61
  /** Session keys whose transcript/session.message updates are currently suppressed. */
46
62
  suppressedSessions = new Set();
47
63
  suppressedSessionTimers = new Map();
48
- /** Session keys whose delivery is owned by this bridge's chatSend final path. */
49
- ownedDeliverySessions = new Set();
50
- ownedDeliverySessionTimers = new Map();
64
+ /** Run ids whose final delivery is owned by chatSend/collectReply. Delivery
65
+ * ownership must be run-scoped: a concurrent queueMode=steer admission may
66
+ * fall through to a separate ordinary run in the same session. */
67
+ ownedDeliveryRuns = new Set();
68
+ ownedDeliveryRunTimers = new Map();
51
69
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
52
70
  mutedProactiveSessions = new Set();
53
71
  mutedProactiveSessionCounts = new Map();
@@ -105,19 +123,47 @@ export class OpenClawClient {
105
123
  },
106
124
  role: "operator",
107
125
  scopes: ["operator.read", "operator.write", "operator.admin"],
126
+ caps: ["tool-events"],
108
127
  auth: { token: this.config.token },
109
128
  userAgent: "openclaw-lark-multi-agent/1.0.0",
110
129
  },
111
130
  }));
112
131
  }
113
132
  else if (frame.type === "res" && frame.ok && frame.payload?.type === "hello-ok") {
133
+ const negotiatedProtocol = Number(frame.payload.protocol);
134
+ if (negotiatedProtocol !== GATEWAY_PROTOCOL_MAX) {
135
+ reject(new Error(`Unsupported Gateway protocol ${frame.payload.protocol}; LMA requires protocol ${GATEWAY_PROTOCOL_MAX}`));
136
+ this.ws?.close();
137
+ return;
138
+ }
139
+ const advertisedMethods = Array.isArray(frame.payload.features?.methods)
140
+ ? new Set(frame.payload.features.methods)
141
+ : null;
142
+ if (advertisedMethods) {
143
+ const missing = REQUIRED_GATEWAY_METHODS.filter((method) => !advertisedMethods.has(method));
144
+ if (missing.length > 0) {
145
+ reject(new Error(`Gateway is missing required methods: ${missing.join(", ")}`));
146
+ this.ws?.close();
147
+ return;
148
+ }
149
+ }
150
+ const advertisedEvents = Array.isArray(frame.payload.features?.events)
151
+ ? new Set(frame.payload.features.events)
152
+ : null;
153
+ if (advertisedEvents) {
154
+ const missing = REQUIRED_GATEWAY_EVENTS.filter((event) => !advertisedEvents.has(event));
155
+ if (missing.length > 0) {
156
+ reject(new Error(`Gateway is missing required events: ${missing.join(", ")}`));
157
+ this.ws?.close();
158
+ return;
159
+ }
160
+ }
114
161
  handshakeDone = true;
115
162
  this.connected = true;
116
163
  console.log("[OpenClaw] Connected to Gateway WS");
117
164
  // Re-subscribe all previously subscribed sessions
118
165
  for (const key of this.subscribedKeys) {
119
- this.rpc("sessions.messages.subscribe", { key }).catch(() => { });
120
- this.rpc("sessions.messages.subscribe", { key: `agent:main:${key}` }).catch(() => { });
166
+ this.rpc("sessions.messages.subscribe", { key: this.canonicalSessionKey(key) }).catch(() => { });
121
167
  }
122
168
  resolve();
123
169
  }
@@ -232,7 +278,7 @@ export class OpenClawClient {
232
278
  data: { deltaText: visibleAssistantText, delta: visibleAssistantText, replace: true },
233
279
  });
234
280
  }
235
- this.handleProactiveSessionMessage(rawKey, msg);
281
+ this.handleProactiveSessionMessage(rawKey, msg, frame.payload.runId);
236
282
  // Tool calls in assistant messages — skip, using agent item events instead
237
283
  // (session.message toolCall events are batched, not real-time)
238
284
  }
@@ -261,15 +307,49 @@ export class OpenClawClient {
261
307
  this.clearVerboseAssistantState(rawKey);
262
308
  }
263
309
  if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
264
- const phase = data.phase || "event";
265
- const meta = data.meta || data.input || data.args || "";
310
+ const phase = data.phase;
311
+ const eventId = String(data.toolCallId || data.itemId || data.id || data.name);
312
+ if (this.claimToolEvent(rawKey, eventId, phase)) {
313
+ const meta = data.meta || data.input || data.args || "";
314
+ const output = data.output || data.result || data.error || "";
315
+ if (toolCb)
316
+ toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
317
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
318
+ if (progressCb) {
319
+ const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
320
+ void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
321
+ }
322
+ }
323
+ }
324
+ }
325
+ // OpenClaw 2026.8 canonical tool stream. Some runtimes also emit the
326
+ // legacy item mirror, so claimToolEvent suppresses duplicate start/end.
327
+ if (frame.event === "agent" && frame.payload?.stream === "tool") {
328
+ const data = frame.payload.data || {};
329
+ const rawKey = frame.payload.sessionKey || "";
330
+ const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
331
+ const rawPhase = String(data.phase || "");
332
+ const phase = rawPhase === "start"
333
+ ? "start"
334
+ : rawPhase === "result"
335
+ ? (data.isError || data.error ? "error" : "end")
336
+ : undefined;
337
+ const name = String(data.name || data.toolName || "tool");
338
+ const eventId = String(data.toolCallId || data.itemId || data.id || name);
339
+ if (phase && this.claimToolEvent(rawKey, eventId, phase)) {
340
+ if (phase === "start") {
341
+ this.flushVerboseAssistantState(rawKey);
342
+ this.clearVerboseAssistantState(rawKey);
343
+ }
344
+ const input = data.input || data.args || data.meta || "";
266
345
  const output = data.output || data.result || data.error || "";
346
+ const toolCb = this.toolEventCallbacks.get(rawKey) || this.toolEventCallbacks.get(shortKey);
267
347
  if (toolCb)
268
- toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
348
+ toolCb(`${name} ${phase}`, String(input || ""), String(output || ""));
269
349
  const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
270
350
  if (progressCb) {
271
- const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
272
- void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
351
+ const detail = phase === "start" ? String(input || "") : String(output || input || "");
352
+ void progressCb({ kind: "tool", phase, name, text: `${name} ${phase}${detail ? `: ${detail}` : ""}` });
273
353
  }
274
354
  }
275
355
  }
@@ -371,6 +451,22 @@ export class OpenClawClient {
371
451
  this.verboseAssistantLastTouched.delete(key);
372
452
  }
373
453
  }
454
+ claimToolEvent(sessionKey, eventId, phase, now = Date.now()) {
455
+ const normalizedPhase = phase === "error" ? "end" : phase;
456
+ const normalizedId = eventId.replace(/^(?:tool|command):/, "");
457
+ const key = `${this.shortKey(sessionKey)}:${normalizedId}:${normalizedPhase}`;
458
+ const seenAt = this.recentToolEventKeys.get(key);
459
+ if (seenAt !== undefined && now - seenAt < 60_000)
460
+ return false;
461
+ this.recentToolEventKeys.set(key, now);
462
+ if (this.recentToolEventKeys.size > 2000) {
463
+ for (const [candidate, ts] of this.recentToolEventKeys) {
464
+ if (now - ts > 60_000)
465
+ this.recentToolEventKeys.delete(candidate);
466
+ }
467
+ }
468
+ return true;
469
+ }
374
470
  pruneVerboseCaches(now = Date.now()) {
375
471
  for (const [key, ts] of this.verboseAssistantLastTouched) {
376
472
  if (now - ts > 10 * 60_000) {
@@ -388,6 +484,10 @@ export class OpenClawClient {
388
484
  for (const key of Array.from(this.lastToolNamesByItemId.keys()).slice(0, overflow))
389
485
  this.lastToolNamesByItemId.delete(key);
390
486
  }
487
+ for (const [key, ts] of this.recentToolEventKeys) {
488
+ if (now - ts > 60_000)
489
+ this.recentToolEventKeys.delete(key);
490
+ }
391
491
  }
392
492
  flushVerboseAssistantState(rawKey) {
393
493
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
@@ -444,7 +544,7 @@ export class OpenClawClient {
444
544
  this.flushVerboseAssistantState(rawKey);
445
545
  }
446
546
  }
447
- handleProactiveSessionMessage(rawKey, msg) {
547
+ handleProactiveSessionMessage(rawKey, msg, runId) {
448
548
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
449
549
  // Proactive assistant text messages. Cron/session-targeted runs often emit
450
550
  // structured content arrays rather than a plain string. Extract only visible
@@ -454,6 +554,10 @@ export class OpenClawClient {
454
554
  const proactiveText = this.extractVisibleAssistantText(msg, { allowMixedToolText: allowVerboseTranscript });
455
555
  if (!proactiveText)
456
556
  return false;
557
+ if (runId && this.ownedDeliveryRuns.has(runId)) {
558
+ console.log(`[OpenClaw] Dropping proactive msg for ${shortKey}; run delivery is owned by chatSend`);
559
+ return false;
560
+ }
457
561
  if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey)) {
458
562
  console.log(`[OpenClaw] Dropping proactive msg for ${shortKey}; delivery is owned by the caller`);
459
563
  return false;
@@ -463,8 +567,12 @@ export class OpenClawClient {
463
567
  return false;
464
568
  }
465
569
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
466
- if (cb)
467
- cb(proactiveText);
570
+ if (cb) {
571
+ if (runId)
572
+ cb(proactiveText, { runId });
573
+ else
574
+ cb(proactiveText);
575
+ }
468
576
  return Boolean(cb);
469
577
  }
470
578
  scheduleReconnect() {
@@ -998,7 +1106,7 @@ export class OpenClawClient {
998
1106
  : 0;
999
1107
  const elapsed = elapsedSeconds > 0 ? `(运行约 ${this.formatDurationSeconds(elapsedSeconds)})` : "";
1000
1108
  const phase = timeoutPhase ? `,阶段:${timeoutPhase}` : "";
1001
- return `Agent 运行超时${elapsed}${phase},已被 OpenClaw 终止`;
1109
+ return `等待 Agent 运行结果超时${elapsed}${phase};底层运行可能仍在继续`;
1002
1110
  }
1003
1111
  if (rawError)
1004
1112
  return rawError;
@@ -1035,53 +1143,51 @@ export class OpenClawClient {
1035
1143
  }
1036
1144
  // --- Session management ---
1037
1145
  async createSession(params) {
1038
- return this.rpc("sessions.create", params);
1146
+ return this.rpc("sessions.create", { ...params, key: this.canonicalSessionKey(params.key) });
1039
1147
  }
1040
1148
  async patchSession(params) {
1041
- return this.rpc("sessions.patch", params, 10000);
1149
+ return this.rpc("sessions.patch", { ...params, key: this.canonicalSessionKey(params.key) }, 10000);
1042
1150
  }
1043
1151
  async getSessionStatus(key) {
1044
- return this.rpc("sessions.describe", { key });
1152
+ return this.rpc("sessions.describe", { key: this.canonicalSessionKey(key) });
1045
1153
  }
1046
1154
  async injectAssistantMessage(params) {
1047
- return this.rpc("chat.inject", params, 10000);
1155
+ return this.rpc("chat.inject", { ...params, sessionKey: this.canonicalSessionKey(params.sessionKey) }, 10000);
1048
1156
  }
1049
1157
  /**
1050
- * Steer a message into the ACTIVE run for a session via the lma-steer plugin's
1051
- * `lma.steer` gateway method. Unlike a plain new message, this injects the text
1052
- * at the next tool-call boundary of the run that is currently executing, so the
1053
- * user can nudge/correct a long run instead of waiting for it to finish.
1054
- *
1055
- * Returns a structured status so the bridge can render the right reaction:
1056
- * - "steered" -> queued into the active run (will be seen next boundary)
1057
- * - "no_active_run" -> no active run; caller should fall back to normal queueing
1058
- * - "rejected" -> active run refused the injection (compacting/not streaming)
1059
- * - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
1060
- * installed or gateway not restarted); caller falls back.
1158
+ * Submit a mid-run message through OpenClaw's public atomic steer path.
1159
+ * OpenClaw 2026.8 deprecated the synchronous plugin primitive previously used
1160
+ * by `lma-steer`: its boolean only represented immediate eligibility and could
1161
+ * still be rejected asynchronously. `chat.send queueMode=steer` owns admission
1162
+ * and transcript persistence. LMA still treats the RPC result as provisional;
1163
+ * the Feishu reaction flips to Get only after a matching `session.message`
1164
+ * confirms that the input was committed/consumed.
1061
1165
  */
1062
1166
  async steer(sessionKey, text, displayText) {
1167
+ const key = this.canonicalSessionKey(sessionKey);
1063
1168
  try {
1064
- const res = await this.rpc("lma.steer", { sessionKey, text }, 10000);
1169
+ const res = await this.rpc("chat.send", {
1170
+ sessionKey: key,
1171
+ message: text,
1172
+ queueMode: "steer",
1173
+ deliver: false,
1174
+ idempotencyKey: randomUUID(),
1175
+ }, 10000);
1065
1176
  const status = res?.status;
1066
- if (status === "steered" || status === "no_active_run" || status === "rejected") {
1067
- // On a successful steer, remember the text so that when the model
1068
- // actually consumes it (a matching sessionUser event on THIS run) we can
1069
- // render it on the live-status card in correct time order and let the
1070
- // bridge flip the reaction to Get only after real consumption. Match on
1071
- // the (possibly wrapped) text actually sent; display the original.
1072
- if (status === "steered")
1073
- this.registerPendingSteer(sessionKey, text, displayText);
1074
- return { status, sessionId: typeof res?.sessionId === "string" ? res.sessionId : undefined };
1177
+ if (status !== "started" && status !== "in_flight" && status !== "ok") {
1178
+ return { status: "unavailable" };
1075
1179
  }
1076
- return { status: "unavailable" };
1180
+ // This records only a provisional accepted input. Actual success is driven
1181
+ // by handleSteerConsumption after the canonical transcript event arrives.
1182
+ const cancelPending = this.registerPendingSteer(key, text, displayText);
1183
+ return {
1184
+ status: "steered",
1185
+ runId: typeof res?.runId === "string" ? res.runId : undefined,
1186
+ cancelPending,
1187
+ };
1077
1188
  }
1078
1189
  catch (err) {
1079
- // Unknown method / plugin missing / transient RPC error -> caller falls back
1080
- // to normal queueing. Never throw into the message path.
1081
- const msg = err?.message || String(err);
1082
- if (!/unknown method/i.test(msg)) {
1083
- console.warn(`[OpenClaw] lma.steer failed for ${sessionKey.slice(-8)}: ${msg}`);
1084
- }
1190
+ console.warn(`[OpenClaw] native steer failed for ${key.slice(-8)}: ${err?.message || String(err)}`);
1085
1191
  return { status: "unavailable" };
1086
1192
  }
1087
1193
  }
@@ -1095,29 +1201,71 @@ export class OpenClawClient {
1095
1201
  * is actually consumed by the active run. The bridge uses this to flip the
1096
1202
  * Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
1097
1203
  * Return true from the callback to consume/remove it (one-shot). */
1098
- onSteerConsumed(sessionKey, cb) {
1204
+ onSteerConsumed(sessionKey, matchText, cb) {
1099
1205
  const key = this.shortKey(sessionKey);
1206
+ const normalizedMatch = matchText.replace(/\s+/g, " ").trim();
1100
1207
  let list = this.steerConsumedCallbacks.get(key);
1101
1208
  if (!list) {
1102
1209
  list = [];
1103
1210
  this.steerConsumedCallbacks.set(key, list);
1104
1211
  }
1105
- list.push(cb);
1212
+ const entry = { matchText: normalizedMatch, cb };
1213
+ list.push(entry);
1214
+ let active = true;
1215
+ const remove = () => {
1216
+ if (!active)
1217
+ return;
1218
+ active = false;
1219
+ clearTimeout(expiry);
1220
+ const current = this.steerConsumedCallbacks.get(key);
1221
+ if (!current)
1222
+ return;
1223
+ const next = current.filter((candidate) => candidate !== entry);
1224
+ if (next.length > 0)
1225
+ this.steerConsumedCallbacks.set(key, next);
1226
+ else
1227
+ this.steerConsumedCallbacks.delete(key);
1228
+ };
1229
+ const expiry = setTimeout(remove, 30 * 60_000);
1230
+ expiry.unref?.();
1231
+ return remove;
1106
1232
  }
1107
1233
  shortKey(sessionKey) {
1108
1234
  return sessionKey.replace(/^agent:[^:]+:/, "");
1109
1235
  }
1236
+ canonicalSessionKey(sessionKey) {
1237
+ return /^agent:[^:]+:/.test(sessionKey) ? sessionKey : `agent:main:${sessionKey}`;
1238
+ }
1110
1239
  registerPendingSteer(sessionKey, text, displayText) {
1111
1240
  const key = this.shortKey(sessionKey);
1112
- const norm = text.replace(/\s+/g, " ").trim();
1113
- if (!norm)
1114
- return;
1115
- let map = this.pendingSteerTexts.get(key);
1116
- if (!map) {
1117
- map = new Map();
1118
- this.pendingSteerTexts.set(key, map);
1119
- }
1120
- map.set(norm, (displayText || text).trim());
1241
+ const submittedText = text.replace(/\s+/g, " ").trim();
1242
+ if (!submittedText)
1243
+ return () => { };
1244
+ const displayTextValue = (displayText || text).trim();
1245
+ const queue = this.pendingSteerTexts.get(key) || [];
1246
+ const entry = { submittedText, displayText: displayTextValue };
1247
+ queue.push(entry);
1248
+ this.pendingSteerTexts.set(key, queue);
1249
+ let active = true;
1250
+ const remove = () => {
1251
+ if (!active)
1252
+ return;
1253
+ active = false;
1254
+ clearTimeout(expiry);
1255
+ const current = this.pendingSteerTexts.get(key);
1256
+ if (!current)
1257
+ return;
1258
+ const next = current.filter((candidate) => candidate !== entry);
1259
+ if (next.length > 0)
1260
+ this.pendingSteerTexts.set(key, next);
1261
+ else
1262
+ this.pendingSteerTexts.delete(key);
1263
+ };
1264
+ // Expire only this provisional correlation. A much later transcript must
1265
+ // not consume a stale duplicate while another identical steer is pending.
1266
+ const expiry = setTimeout(remove, 30 * 60_000);
1267
+ expiry.unref?.();
1268
+ return remove;
1121
1269
  }
1122
1270
  /**
1123
1271
  * Called when a sessionUser event is seen for a session. If its text matches a
@@ -1127,44 +1275,49 @@ export class OpenClawClient {
1127
1275
  */
1128
1276
  handleSteerConsumption(sessionKey, text) {
1129
1277
  const key = this.shortKey(sessionKey);
1130
- const map = this.pendingSteerTexts.get(key);
1131
- if (!map || map.size === 0)
1278
+ const queue = this.pendingSteerTexts.get(key);
1279
+ if (!queue || queue.length === 0)
1132
1280
  return false;
1133
1281
  const norm = (text || "").replace(/\s+/g, " ").trim();
1134
1282
  if (!norm)
1135
1283
  return false;
1136
- // Match exact, or the sessionUser text containing the steered text (the run
1137
- // may wrap it). Prefer the longest matching pending entry.
1138
- let matched;
1139
- for (const pending of map.keys()) {
1140
- if (norm === pending || norm.includes(pending)) {
1141
- if (!matched || pending.length > matched.length)
1142
- matched = pending;
1284
+ // One transcript event consumes exactly one pending steer. Prefer an exact
1285
+ // match, otherwise the longest submitted wrapper contained in the event.
1286
+ let matchedIndex = queue.findIndex((pending) => norm === pending.submittedText);
1287
+ if (matchedIndex < 0) {
1288
+ let matchedLength = -1;
1289
+ for (let i = 0; i < queue.length; i++) {
1290
+ const pending = queue[i];
1291
+ if (norm.includes(pending.submittedText) && pending.submittedText.length > matchedLength) {
1292
+ matchedIndex = i;
1293
+ matchedLength = pending.submittedText.length;
1294
+ }
1143
1295
  }
1144
1296
  }
1145
- if (!matched)
1297
+ if (matchedIndex < 0)
1146
1298
  return false;
1147
- const display = map.get(matched) || matched;
1148
- map.delete(matched);
1149
- if (map.size === 0)
1299
+ const [matched] = queue.splice(matchedIndex, 1);
1300
+ if (queue.length === 0)
1150
1301
  this.pendingSteerTexts.delete(key);
1151
1302
  const progressCb = this.progressCallbacks.get(key) || this.progressCallbacks.get(`agent:main:${key}`);
1152
1303
  if (progressCb)
1153
- void progressCb({ kind: "steer", text: display });
1304
+ void progressCb({ kind: "steer", text: matched.displayText });
1154
1305
  const list = this.steerConsumedCallbacks.get(key);
1155
1306
  if (list && list.length > 0) {
1156
- // Invoke callbacks; a callback returning true is one-shot and removed.
1157
- const remaining = list.filter((cb) => {
1307
+ // Match and remove exactly one observer for this consumed steer. This is
1308
+ // important for duplicate text: one session.message must acknowledge one
1309
+ // Feishu message, never all identical messages at once.
1310
+ let callbackIndex = list.findIndex((entry) => entry.matchText === matched.submittedText);
1311
+ if (callbackIndex < 0)
1312
+ callbackIndex = list.findIndex((entry) => norm.includes(entry.matchText));
1313
+ if (callbackIndex >= 0) {
1314
+ const [entry] = list.splice(callbackIndex, 1);
1158
1315
  try {
1159
- return cb(matched) !== true;
1316
+ entry.cb(matched.submittedText);
1160
1317
  }
1161
- catch {
1162
- return true;
1163
- }
1164
- });
1165
- if (remaining.length > 0)
1166
- this.steerConsumedCallbacks.set(key, remaining);
1167
- else
1318
+ catch { /* observer failures are isolated */ }
1319
+ }
1320
+ if (list.length === 0)
1168
1321
  this.steerConsumedCallbacks.delete(key);
1169
1322
  }
1170
1323
  return true;
@@ -1173,41 +1326,57 @@ export class OpenClawClient {
1173
1326
  * Get session info (model, tokens, etc.) for status display.
1174
1327
  */
1175
1328
  async getSessionInfo(sessionKey) {
1176
- return this.rpc("sessions.describe", { key: sessionKey });
1329
+ return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
1177
1330
  }
1178
1331
  /**
1179
1332
  * Ensure session is using the expected model. If not, patch it back.
1180
1333
  * Returns true if a correction was made.
1181
1334
  */
1182
1335
  async ensureModel(sessionKey, expectedModel) {
1336
+ const info = await this.getSessionInfo(sessionKey).catch(() => null);
1337
+ const session = info?.session;
1338
+ const modelId = typeof session?.model === "string" ? session.model.trim() : "";
1339
+ const provider = typeof session?.modelProvider === "string"
1340
+ ? session.modelProvider.trim()
1341
+ : typeof session?.provider === "string" ? session.provider.trim() : "";
1342
+ const currentModel = modelId.includes("/") ? modelId : provider && modelId ? `${provider}/${modelId}` : modelId;
1343
+ if (currentModel === expectedModel)
1344
+ return false;
1183
1345
  try {
1184
- // Always patch to ensure model is correct — describe may return internal model names
1185
- // Use short timeout as sessions.patch may not return a response
1186
- await this.patchSession({ key: sessionKey, model: expectedModel }).catch(() => { });
1187
- // Also try with full key prefix
1188
- await this.patchSession({ key: `agent:main:${sessionKey}`, model: expectedModel }).catch(() => { });
1189
- console.log(`[OpenClaw] Model ensured: ${sessionKey} → ${expectedModel}`);
1346
+ await this.patchSession({ key: sessionKey, model: expectedModel });
1190
1347
  }
1191
- catch (err) {
1192
- console.warn(`[OpenClaw] ensureModel patch failed:`, err.message);
1348
+ catch (firstErr) {
1349
+ // Older gateways occasionally require the fully-qualified key. Do not hide
1350
+ // model-policy/capability errors: retry only when the key itself was not
1351
+ // resolved, otherwise surface the actionable rejection to the caller.
1352
+ const message = firstErr.message || String(firstErr);
1353
+ if (!/session (?:not found|unknown)|unknown session|invalid session key/i.test(message))
1354
+ throw firstErr;
1355
+ const fullKey = sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
1356
+ await this.patchSession({ key: fullKey, model: expectedModel });
1193
1357
  }
1194
- return false;
1358
+ console.log(`[OpenClaw] Model ensured: ${sessionKey} → ${expectedModel}`);
1359
+ return Boolean(currentModel && currentModel !== expectedModel);
1195
1360
  }
1196
1361
  async deleteSession(key, deleteTranscript = true) {
1197
- return this.rpc("sessions.delete", { key, deleteTranscript });
1362
+ return this.rpc("sessions.delete", { key: this.canonicalSessionKey(key), deleteTranscript });
1198
1363
  }
1199
1364
  async resetSession(key) {
1200
1365
  // sessions.reset may not return a response; use short timeout
1201
- return this.rpc("sessions.reset", { key }, 5000).catch(() => { });
1366
+ return this.rpc("sessions.reset", { key: this.canonicalSessionKey(key) }, 5000).catch(() => { });
1202
1367
  }
1203
- async compactSession(key) {
1368
+ async compactSession(key, options = {}) {
1204
1369
  const release = await this.acquireMaintenanceSlot("sessions.compact");
1205
1370
  const startedAt = Date.now();
1371
+ const maxLines = Number.isFinite(options.maxLines)
1372
+ ? Math.max(1, Math.floor(options.maxLines))
1373
+ : undefined;
1206
1374
  try {
1207
- return await this.rpc("sessions.compact", { key }, 10 * 60 * 1000);
1375
+ return await this.rpc("sessions.compact", { key: this.canonicalSessionKey(key), ...(maxLines !== undefined ? { maxLines } : {}) }, 10 * 60 * 1000);
1208
1376
  }
1209
1377
  finally {
1210
- console.log(`[OpenClaw] sessions.compact finished for ${key} in ${Date.now() - startedAt}ms`);
1378
+ const mode = maxLines === undefined ? "semantic" : `maxLines=${maxLines}`;
1379
+ console.log(`[OpenClaw] sessions.compact (${mode}) finished for ${key} in ${Date.now() - startedAt}ms`);
1211
1380
  release();
1212
1381
  }
1213
1382
  }
@@ -1217,50 +1386,50 @@ export class OpenClawClient {
1217
1386
  * deliver=false prevents OpenClaw from auto-posting to channels.
1218
1387
  */
1219
1388
  async abortChat(sessionKey, runId) {
1220
- const key = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
1389
+ const key = this.canonicalSessionKey(sessionKey);
1221
1390
  // chat.abort supports { sessionKey } with no runId to abort ALL active runs
1222
1391
  // for that session. Used by /stop to force-clear a stuck run.
1223
1392
  if (!runId) {
1224
1393
  // Mark the session so any in-flight collectReply finishes immediately
1225
1394
  // instead of treating the cancelled lifecycle as a transient state.
1226
1395
  this.forceAbortedSessions.add(key);
1227
- this.forceAbortedSessions.add(`agent:main:${key}`);
1396
+ this.forceAbortedSessions.add(this.shortKey(key));
1228
1397
  }
1229
1398
  const params = runId ? { sessionKey: key, runId } : { sessionKey: key };
1230
1399
  return this.rpc("chat.abort", params, 5000).catch(() => { });
1231
1400
  }
1232
1401
  sessionKeyVariants(key) {
1233
- const shortKey = key.startsWith("agent:main:") ? key.slice("agent:main:".length) : key;
1234
- return [shortKey, `agent:main:${shortKey}`];
1402
+ const shortKey = this.shortKey(key);
1403
+ return Array.from(new Set([shortKey, this.canonicalSessionKey(key), key]));
1235
1404
  }
1236
1405
  trackChatEventSession(sessionKey, state, payload) {
1237
1406
  if (!sessionKey || sessionKey === "__default__")
1238
1407
  return;
1239
1408
  const keys = this.sessionKeyVariants(sessionKey);
1240
- if (this.isOwnedDeliverySession(sessionKey)) {
1241
- // This chat event belongs to a bridge-owned chat.send run. The final answer
1242
- // is delivered by collectReply/processQueue, so transcript session.message
1243
- // mirrors must stay suppressed briefly.
1244
- this.suppressSessionKeys(keys);
1245
- if (state === "final" || state === "error" || state === "aborted")
1246
- this.releaseSuppressedSessionKeysAfter(keys, 30000);
1409
+ const runId = typeof payload?.runId === "string" ? payload.runId : "";
1410
+ if (runId && this.ownedDeliveryRuns.has(runId)) {
1411
+ // collectReply owns this exact run. Do not suppress the whole session:
1412
+ // queueMode=steer may concurrently create another ordinary run that must
1413
+ // still reach the proactive callback.
1247
1414
  return;
1248
1415
  }
1249
- // External WebChat/Control UI chat against an LMA session: do not forward
1250
- // streaming transcript updates, but allow the final chat message to be
1251
- // delivered through the proactive callback after it is committed.
1416
+ // External WebChat/Control UI (including a steer admission that became a
1417
+ // separate run): hide streaming mirrors, then emit its final exactly once.
1252
1418
  if (state === "delta") {
1253
1419
  this.suppressSessionKeys(keys);
1254
1420
  }
1255
1421
  else if (state === "final") {
1256
1422
  const text = this.extractTextFromChatMessage(payload?.message);
1257
1423
  if (text)
1258
- this.emitProactiveForSession(sessionKey, text);
1259
- // Keep transcript mirrors suppressed briefly; chat final already emitted
1260
- // the user-visible result for external WebChat/Control UI turns.
1424
+ this.emitProactiveForSession(sessionKey, text, runId);
1261
1425
  this.releaseSuppressedSessionKeysAfter(keys, 30000);
1262
1426
  }
1263
1427
  else if (state === "error" || state === "aborted") {
1428
+ const detail = String(payload?.errorMessage || payload?.error || payload?.reason || "").trim();
1429
+ const text = state === "error"
1430
+ ? `⚠️ Agent run failed${detail ? `: ${detail}` : ""}`
1431
+ : `⚠️ Agent run was aborted${detail ? `: ${detail}` : ""}`;
1432
+ this.emitProactiveForSession(sessionKey, text, runId, "run_error");
1264
1433
  this.releaseSuppressedSessionKeysAfter(keys, 0);
1265
1434
  }
1266
1435
  }
@@ -1268,12 +1437,15 @@ export class OpenClawClient {
1268
1437
  const parts = Array.isArray(message?.content) ? message.content : [];
1269
1438
  return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
1270
1439
  }
1271
- emitProactiveForSession(sessionKey, text) {
1440
+ emitProactiveForSession(sessionKey, text, runId, sourceType) {
1272
1441
  const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
1273
1442
  const cb = this.sessionMessageCallbacks.get(fullKey) || this.sessionMessageCallbacks.get(shortKey);
1274
1443
  if (!cb)
1275
1444
  return false;
1276
- cb(text);
1445
+ if (runId || sourceType)
1446
+ cb(text, { ...(runId ? { runId } : {}), ...(sourceType ? { sourceType } : {}) });
1447
+ else
1448
+ cb(text);
1277
1449
  return true;
1278
1450
  }
1279
1451
  suppressSessionKeys(keys) {
@@ -1297,30 +1469,27 @@ export class OpenClawClient {
1297
1469
  this.suppressedSessionTimers.set(key, timer);
1298
1470
  }
1299
1471
  }
1300
- ownDeliverySessionKeys(keys) {
1301
- for (const key of keys) {
1302
- const timer = this.ownedDeliverySessionTimers.get(key);
1303
- if (timer)
1304
- clearTimeout(timer);
1305
- this.ownedDeliverySessionTimers.delete(key);
1306
- this.ownedDeliverySessions.add(key);
1307
- }
1308
- }
1309
- releaseOwnedDeliverySessionKeysAfter(keys, delayMs) {
1310
- for (const key of keys) {
1311
- const oldTimer = this.ownedDeliverySessionTimers.get(key);
1312
- if (oldTimer)
1313
- clearTimeout(oldTimer);
1314
- const timer = setTimeout(() => {
1315
- this.ownedDeliverySessions.delete(key);
1316
- this.ownedDeliverySessionTimers.delete(key);
1317
- }, delayMs);
1318
- this.ownedDeliverySessionTimers.set(key, timer);
1319
- }
1472
+ ownDeliveryRun(runId) {
1473
+ if (!runId)
1474
+ return;
1475
+ const timer = this.ownedDeliveryRunTimers.get(runId);
1476
+ if (timer)
1477
+ clearTimeout(timer);
1478
+ this.ownedDeliveryRunTimers.delete(runId);
1479
+ this.ownedDeliveryRuns.add(runId);
1320
1480
  }
1321
- isOwnedDeliverySession(sessionKey) {
1322
- const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
1323
- return this.ownedDeliverySessions.has(shortKey) || this.ownedDeliverySessions.has(fullKey);
1481
+ releaseOwnedDeliveryRunAfter(runId, delayMs) {
1482
+ if (!runId)
1483
+ return;
1484
+ const oldTimer = this.ownedDeliveryRunTimers.get(runId);
1485
+ if (oldTimer)
1486
+ clearTimeout(oldTimer);
1487
+ const timer = setTimeout(() => {
1488
+ this.ownedDeliveryRuns.delete(runId);
1489
+ this.ownedDeliveryRunTimers.delete(runId);
1490
+ }, delayMs);
1491
+ timer.unref?.();
1492
+ this.ownedDeliveryRunTimers.set(runId, timer);
1324
1493
  }
1325
1494
  addMutedProactiveKey(key) {
1326
1495
  const count = this.mutedProactiveSessionCounts.get(key) || 0;
@@ -1395,12 +1564,10 @@ export class OpenClawClient {
1395
1564
  next();
1396
1565
  }
1397
1566
  async chatSend(params) {
1398
- const sk = params.sessionKey;
1399
- const fullSessionKey = `agent:main:${sk}`;
1400
- const suppressedKeys = [sk, fullSessionKey];
1401
- this.suppressSessionKeys(suppressedKeys);
1402
- this.ownDeliverySessionKeys(suppressedKeys);
1567
+ const sk = this.shortKey(params.sessionKey);
1568
+ const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
1403
1569
  this.clearVerboseAssistantState(sk);
1570
+ let ownedRunId = "";
1404
1571
  try {
1405
1572
  // Drop stale buffered events for this session before starting a new run.
1406
1573
  // This prevents an old final text (e.g. previous "ok") from being consumed by
@@ -1413,7 +1580,7 @@ export class OpenClawClient {
1413
1580
  try {
1414
1581
  await params.onSendAttempt?.();
1415
1582
  result = await this.rpc("chat.send", {
1416
- sessionKey: sk,
1583
+ sessionKey: this.canonicalSessionKey(sk),
1417
1584
  message: params.message,
1418
1585
  attachments: params.attachments,
1419
1586
  deliver: params.deliver ?? false,
@@ -1423,6 +1590,8 @@ export class OpenClawClient {
1423
1590
  finally {
1424
1591
  releaseChatSendSlot();
1425
1592
  }
1593
+ ownedRunId = typeof result?.runId === "string" ? result.runId : "";
1594
+ this.ownDeliveryRun(ownedRunId);
1426
1595
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
1427
1596
  await params.onSubmitted?.(result.runId);
1428
1597
  if (params.onProgress) {
@@ -1439,8 +1608,7 @@ export class OpenClawClient {
1439
1608
  // are unaffected because they do not go through chatSend.
1440
1609
  this.progressCallbacks.delete(sk);
1441
1610
  this.progressCallbacks.delete(fullSessionKey);
1442
- this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
1443
- this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
1611
+ this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
1444
1612
  }
1445
1613
  }
1446
1614
  shouldInjectBridgeAttachmentHint(text) {
@@ -1630,9 +1798,9 @@ export class OpenClawClient {
1630
1798
  this.suppressedSessions.clear();
1631
1799
  this.mutedProactiveSessions.clear();
1632
1800
  this.mutedProactiveSessionCounts.clear();
1633
- for (const timer of this.ownedDeliverySessionTimers.values())
1801
+ for (const timer of this.ownedDeliveryRunTimers.values())
1634
1802
  clearTimeout(timer);
1635
- this.ownedDeliverySessionTimers.clear();
1803
+ this.ownedDeliveryRunTimers.clear();
1636
1804
  for (const timer of this.verboseAssistantTimers.values())
1637
1805
  clearTimeout(timer);
1638
1806
  this.verboseAssistantTimers.clear();
@@ -1640,7 +1808,10 @@ export class OpenClawClient {
1640
1808
  this.verboseAssistantSent.clear();
1641
1809
  this.verboseAssistantLastTouched.clear();
1642
1810
  this.lastToolNamesByItemId.clear();
1643
- this.ownedDeliverySessions.clear();
1811
+ this.recentToolEventKeys.clear();
1812
+ this.pendingSteerTexts.clear();
1813
+ this.steerConsumedCallbacks.clear();
1814
+ this.ownedDeliveryRuns.clear();
1644
1815
  if (this.ws) {
1645
1816
  this.ws.close();
1646
1817
  this.ws = null;
@@ -1658,9 +1829,7 @@ export class OpenClawClient {
1658
1829
  this.sessionMessageCallbacks.set(`agent:main:${sessionKey}`, onMessage);
1659
1830
  this.subscribedKeys.add(sessionKey);
1660
1831
  try {
1661
- // Try subscribing with short key first, then full key
1662
- await this.rpc("sessions.messages.subscribe", { key: sessionKey }).catch(() => { });
1663
- await this.rpc("sessions.messages.subscribe", { key: `agent:main:${sessionKey}` }).catch(() => { });
1832
+ await this.rpc("sessions.messages.subscribe", { key: this.canonicalSessionKey(sessionKey) });
1664
1833
  }
1665
1834
  catch (err) {
1666
1835
  console.warn(`[OpenClaw] Failed to subscribe ${sessionKey}:`, err.message);
@@ -1685,7 +1854,7 @@ export class OpenClawClient {
1685
1854
  this.setVerboseTranscriptDelivery(sessionKey, false);
1686
1855
  this.subscribedKeys.delete(sessionKey);
1687
1856
  try {
1688
- await this.rpc("sessions.messages.unsubscribe", { key: sessionKey });
1857
+ await this.rpc("sessions.messages.unsubscribe", { key: this.canonicalSessionKey(sessionKey) });
1689
1858
  }
1690
1859
  catch {
1691
1860
  // ignore