@alfe.ai/openclaw-chat 0.3.3 → 0.5.0

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.
package/dist/plugin2.js CHANGED
@@ -344,6 +344,31 @@ async function addMessage(sessionId, role, content, senderId, senderName) {
344
344
  writeLocks.set(sessionId, next);
345
345
  await next;
346
346
  }
347
+ const MAX_ACTIVITY_PER_SESSION = 500;
348
+ const MAX_ACTIVITY_THINKING_CHARS = 16e3;
349
+ /**
350
+ * Append a turn's terminalized activity records. Called ONCE per turn (from
351
+ * the dispatch `finally`) — never per-delta: thinking deltas are
352
+ * high-frequency and each save rewrites the whole session file.
353
+ */
354
+ async function appendActivity(sessionId, entries) {
355
+ if (entries.length === 0) return;
356
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
357
+ const session = await getSession(sessionId);
358
+ if (!session) return;
359
+ const capped = entries.map((e) => e.kind === "thinking" && e.text.length > MAX_ACTIVITY_THINKING_CHARS ? {
360
+ ...e,
361
+ text: e.text.slice(0, MAX_ACTIVITY_THINKING_CHARS)
362
+ } : e);
363
+ const combined = [...session.activity ?? [], ...capped];
364
+ session.activity = combined.length > MAX_ACTIVITY_PER_SESSION ? combined.slice(combined.length - MAX_ACTIVITY_PER_SESSION) : combined;
365
+ await saveSession(session);
366
+ }).finally(() => {
367
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
368
+ });
369
+ writeLocks.set(sessionId, next);
370
+ await next;
371
+ }
347
372
  async function listSessions(filters, limit = 50) {
348
373
  await ensureDir();
349
374
  let files;
@@ -848,6 +873,101 @@ function extractResultText(result) {
848
873
  return parts.join("\n");
849
874
  }
850
875
  //#endregion
876
+ //#region src/think-tags.ts
877
+ /**
878
+ * Inline `<think>` span handling for assistant text.
879
+ *
880
+ * Reasoning models routed through the AI proxy (DeepSeek `deepseek-*`,
881
+ * Zhipu `glm-*`, …) emit their reasoning INLINE as `<think>…</think>` inside
882
+ * the normal content stream instead of a structured thinking channel. The
883
+ * openclaw-chat plugin (0.4.0+) diverts those spans into thinking activity
884
+ * frames at the source; this relay-side copy is the DEFENSIVE layer for
885
+ * agents still running older plugin versions — leaked reasoning is dropped
886
+ * here, never synthesized into activity (that's the plugin's job).
887
+ *
888
+ * KEEP IN SYNC (byte-identical): this module exists at BOTH
889
+ * `services/chat/src/lib/think-tags.ts` (relay, defensive layer)
890
+ * `packages/openclaw-chat/src/think-tags.ts` (plugin, at-source divert)
891
+ * The published plugin cannot import service internals, so the module is
892
+ * duplicated, same as the ChatActivity shape.
893
+ *
894
+ * Known accepted limitation: a literal `<think>` in legitimate prose or a
895
+ * code fence is treated as reasoning and stripped/diverted.
896
+ */
897
+ const OPEN_TAG = "<think>";
898
+ const CLOSE_TAG = "</think>";
899
+ /**
900
+ * Split CUMULATIVE assistant text into the user-visible part and the
901
+ * reasoning part.
902
+ *
903
+ * Invariant (load-bearing): for successive cumulative snapshots that grow by
904
+ * appending, `visible` is monotonically append-only — `visible(n)` is always
905
+ * a prefix of `visible(n + 1)`. The delta relays slice by a `lastSentLength`
906
+ * offset into this string; if `visible` ever shrank or rewrote earlier
907
+ * characters, clients would receive corrupted deltas. This holds because the
908
+ * scan is deterministic left-to-right and a trailing PARTIAL tag (any strict
909
+ * prefix of the next expected tag, e.g. `"<thi"` or `"</th"`) is HELD BACK
910
+ * from both outputs until later text disambiguates it.
911
+ */
912
+ function splitThinkTags(cumulative) {
913
+ let visible = "";
914
+ let thinking = "";
915
+ let inThink = false;
916
+ let i = 0;
917
+ const n = cumulative.length;
918
+ while (i < n) {
919
+ const tag = inThink ? CLOSE_TAG : OPEN_TAG;
920
+ const idx = cumulative.indexOf(tag, i);
921
+ if (idx !== -1) {
922
+ const chunk = cumulative.slice(i, idx);
923
+ if (inThink) thinking += chunk;
924
+ else visible += chunk;
925
+ inThink = !inThink;
926
+ i = idx + tag.length;
927
+ continue;
928
+ }
929
+ let rest = cumulative.slice(i);
930
+ const held = trailingPartialTagLength(rest, tag);
931
+ if (held > 0) rest = rest.slice(0, rest.length - held);
932
+ if (inThink) thinking += rest;
933
+ else visible += rest;
934
+ break;
935
+ }
936
+ return {
937
+ visible,
938
+ thinking
939
+ };
940
+ }
941
+ /** Length of the longest strict prefix of `tag` that is a suffix of `text`. */
942
+ function trailingPartialTagLength(text, tag) {
943
+ const max = Math.min(text.length, tag.length - 1);
944
+ for (let len = max; len > 0; len--) if (text.endsWith(tag.slice(0, len))) return len;
945
+ return 0;
946
+ }
947
+ /**
948
+ * Strip `<think>…</think>` spans (and an unclosed trailing `<think>…`) from
949
+ * FINAL text, then tidy the whitespace the spans leave behind.
950
+ *
951
+ * Idempotent — `stripThinkSpans(stripThinkSpans(x)) === stripThinkSpans(x)`.
952
+ * New plugin + new relay both strip, so the second pass must be a no-op; the
953
+ * fixpoint loop also covers pathological inputs where removing one span
954
+ * makes the surrounding fragments form a new one.
955
+ *
956
+ * NOT for sliced/streaming paths — the trim + blank-line collapse breaks
957
+ * prefix alignment with `lastSentLength`; those paths use
958
+ * `splitThinkTags(...).visible` instead.
959
+ */
960
+ function stripThinkSpans(text) {
961
+ let out = text;
962
+ let prev;
963
+ do {
964
+ prev = out;
965
+ out = out.replace(/<think>[\s\S]*?<\/think>/g, "");
966
+ } while (out !== prev);
967
+ out = out.replace(/<think>[\s\S]*$/, "");
968
+ return out.replace(/\n{3,}/g, "\n\n").trim();
969
+ }
970
+ //#endregion
851
971
  //#region src/plugin.ts
852
972
  /**
853
973
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -913,7 +1033,7 @@ function asString(v) {
913
1033
  * Returns null when there's nothing to forward.
914
1034
  */
915
1035
  function buildThinkingActivity(data) {
916
- const text = asString(data.delta) ?? asString(data.text) ?? asString(data.content) ?? asString(data.message?.text);
1036
+ const text = asString(data.delta) ?? asString(data.text) ?? asString(data.content) ?? asString(data.reasoning) ?? asString(data.reasoning_content) ?? asString(data.message?.text);
917
1037
  if (!text) return null;
918
1038
  return {
919
1039
  kind: "thinking",
@@ -922,6 +1042,34 @@ function buildThinkingActivity(data) {
922
1042
  };
923
1043
  }
924
1044
  /**
1045
+ * Join the text parts of an assistant-stream event's content array.
1046
+ * Assistant `evt.data` is a message object whose `content` is an array of
1047
+ * typed parts; deltas are CUMULATIVE snapshots (the relay length-compares
1048
+ * them — see adapter.ts's lastSentLength slicing). Non-array/absent content
1049
+ * yields '' (nothing to divert).
1050
+ */
1051
+ function joinAssistantText(data) {
1052
+ const content = data.content;
1053
+ if (!Array.isArray(content)) return "";
1054
+ return content.filter((c) => typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("");
1055
+ }
1056
+ /**
1057
+ * Rebuild an assistant event's message with its text parts replaced by one
1058
+ * `{type:'text', text: visible}` part. Non-text parts are preserved — the
1059
+ * relay's consumers (adapter/inject/voice-bridge) only join text parts, so
1060
+ * relative ordering against non-text items is not load-bearing.
1061
+ */
1062
+ function rewriteAssistantText(data, visible) {
1063
+ const nonText = (Array.isArray(data.content) ? data.content : []).filter((c) => !(typeof c === "object" && c !== null && c.type === "text"));
1064
+ return {
1065
+ ...data,
1066
+ content: [...nonText, {
1067
+ type: "text",
1068
+ text: visible
1069
+ }]
1070
+ };
1071
+ }
1072
+ /**
925
1073
  * Extract structured tool status from an OpenClaw `tool`-stream event.
926
1074
  * We send the emoji-free raw `name` plus a lifecycle `status` and let the
927
1075
  * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
@@ -1038,6 +1186,37 @@ function buildA2ACompletePayload(params) {
1038
1186
  };
1039
1187
  }
1040
1188
  let dispatchInbound = null;
1189
+ let abortEmbeddedAgentRun = null;
1190
+ let resolveActiveRunSessionId = null;
1191
+ let resolveRouteEnvelope = null;
1192
+ /**
1193
+ * Load the OpenClaw SDK surfaces from a single resolver anchor. Returns true
1194
+ * once the critical `dispatchInboundDirectDmWithRuntime` is resolved. The abort
1195
+ * primitives + route resolver are best-effort — an older openclaw without them
1196
+ * degrades `chat.abort` to a logged no-op (Stop still seals the UI) rather than
1197
+ * a hard failure.
1198
+ */
1199
+ function loadOpenClawSdk(req, log, anchor) {
1200
+ let resolvedDispatch = false;
1201
+ try {
1202
+ const channelInbound = req("openclaw/plugin-sdk/channel-inbound");
1203
+ if (channelInbound.dispatchInboundDirectDmWithRuntime) {
1204
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
1205
+ resolvedDispatch = true;
1206
+ }
1207
+ } catch {}
1208
+ try {
1209
+ const harness = req("openclaw/plugin-sdk/agent-harness");
1210
+ if (harness.abortEmbeddedAgentRun) abortEmbeddedAgentRun = harness.abortEmbeddedAgentRun;
1211
+ if (harness.resolveActiveEmbeddedRunSessionId) resolveActiveRunSessionId = harness.resolveActiveEmbeddedRunSessionId;
1212
+ } catch {}
1213
+ try {
1214
+ const envelope = req("openclaw/plugin-sdk/inbound-envelope");
1215
+ if (envelope.resolveInboundRouteEnvelopeBuilderWithRuntime) resolveRouteEnvelope = envelope.resolveInboundRouteEnvelopeBuilderWithRuntime;
1216
+ } catch {}
1217
+ if (resolvedDispatch) log.info(`Resolved OpenClaw SDK from ${anchor} (hard-abort=${abortEmbeddedAgentRun && resolveActiveRunSessionId ? "available" : "unavailable"})`);
1218
+ return resolvedDispatch;
1219
+ }
1041
1220
  /**
1042
1221
  * Resolve OpenClaw SDK functions from the running process.
1043
1222
  *
@@ -1049,21 +1228,11 @@ let dispatchInbound = null;
1049
1228
  function resolveOpenClawSdk(log) {
1050
1229
  const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
1051
1230
  for (const anchor of anchors) try {
1052
- const channelInbound = createRequire(anchor)("openclaw/plugin-sdk/channel-inbound");
1053
- if (channelInbound.dispatchInboundDirectDmWithRuntime) {
1054
- dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
1055
- log.info(`Resolved OpenClaw SDK from ${anchor}`);
1056
- return;
1057
- }
1231
+ if (loadOpenClawSdk(createRequire(anchor), log, anchor)) return;
1058
1232
  } catch {}
1059
1233
  try {
1060
1234
  const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
1061
- const channelInbound = createRequire(derivedPath)("openclaw/plugin-sdk/channel-inbound");
1062
- if (channelInbound.dispatchInboundDirectDmWithRuntime) {
1063
- dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
1064
- log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
1065
- return;
1066
- }
1235
+ if (loadOpenClawSdk(createRequire(derivedPath), log, derivedPath)) return;
1067
1236
  } catch {}
1068
1237
  log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
1069
1238
  }
@@ -1140,11 +1309,111 @@ async function downloadAttachments(attachments, log) {
1140
1309
  }
1141
1310
  return results;
1142
1311
  }
1143
- let agentTurnQueue = Promise.resolve();
1144
- function enqueueAgentTurn(task) {
1145
- const run = agentTurnQueue.then(task, task);
1146
- agentTurnQueue = run.then(() => void 0, () => void 0);
1147
- return run;
1312
+ let agentTurnBacklog = [];
1313
+ let agentTurnDraining = false;
1314
+ function enqueueAgentTurn(task, opts) {
1315
+ return new Promise((resolve, reject) => {
1316
+ const entry = {
1317
+ run: task,
1318
+ coalesceKey: opts?.coalesceKey,
1319
+ onSuperseded: opts?.onSuperseded ?? (() => void 0),
1320
+ resolve,
1321
+ reject
1322
+ };
1323
+ if (entry.coalesceKey) {
1324
+ const survivors = [];
1325
+ for (const queued of agentTurnBacklog) if (queued.coalesceKey === entry.coalesceKey) {
1326
+ try {
1327
+ queued.onSuperseded();
1328
+ } catch {}
1329
+ queued.resolve();
1330
+ } else survivors.push(queued);
1331
+ agentTurnBacklog = survivors;
1332
+ }
1333
+ agentTurnBacklog.push(entry);
1334
+ drainAgentTurns();
1335
+ });
1336
+ }
1337
+ async function drainAgentTurns() {
1338
+ if (agentTurnDraining) return;
1339
+ agentTurnDraining = true;
1340
+ try {
1341
+ while (agentTurnBacklog.length > 0) {
1342
+ const entry = agentTurnBacklog.shift();
1343
+ if (!entry) break;
1344
+ try {
1345
+ await entry.run();
1346
+ entry.resolve();
1347
+ } catch (err) {
1348
+ entry.reject(err);
1349
+ }
1350
+ }
1351
+ } finally {
1352
+ agentTurnDraining = false;
1353
+ }
1354
+ }
1355
+ /**
1356
+ * Test-only: reset the agent-turn backlog between unit tests. Not used at
1357
+ * runtime (the backlog lives for the process lifetime of one daemon).
1358
+ */
1359
+ function __resetAgentTurnQueueForTest() {
1360
+ agentTurnBacklog = [];
1361
+ agentTurnDraining = false;
1362
+ }
1363
+ /** Test-only accessor for the coalescing queue (see __tests__/queue.test.ts). */
1364
+ const __agentTurnQueueForTest = {
1365
+ enqueue: enqueueAgentTurn,
1366
+ backlogLength: () => agentTurnBacklog.length
1367
+ };
1368
+ let activeRun = null;
1369
+ /** Test-only: set/clear the active-run ref for handleChatAbort unit tests. */
1370
+ function __setActiveRunForTest(ref) {
1371
+ activeRun = ref;
1372
+ }
1373
+ /**
1374
+ * Ordered session keys to try when aborting for `abortSessionKey`. Pure, so it
1375
+ * unit-tests without the OpenClaw SDK. The resolved route key (what the run
1376
+ * registered under) is preferred; the raw request key is a best-effort
1377
+ * fallback. An abort with NO sessionKey targets the single active run. A
1378
+ * sessionKey that does NOT match the running turn yields no candidates — we
1379
+ * never abort another session's run on a shared daemon.
1380
+ */
1381
+ function resolveAbortTargetKeys(active, abortSessionKey) {
1382
+ const keys = [];
1383
+ if (active && (!abortSessionKey || active.requestSessionKey === abortSessionKey)) {
1384
+ if (active.routeSessionKey) keys.push(active.routeSessionKey);
1385
+ if (!keys.includes(active.requestSessionKey)) keys.push(active.requestSessionKey);
1386
+ } else if (abortSessionKey) keys.push(abortSessionKey);
1387
+ return keys;
1388
+ }
1389
+ /**
1390
+ * `chat.abort` handler — genuinely cancels the RUNNING turn on the daemon.
1391
+ *
1392
+ * Invoked OUT-OF-BAND (never through the serialised agent-turn queue) so it
1393
+ * executes immediately while a turn is mid-flight. Resolves the live run's
1394
+ * sessionId from the target session key and fires the run handle's
1395
+ * AbortController via `abortEmbeddedAgentRun`. The in-flight dispatch then
1396
+ * settles (AbortError), so `handleAgentRequest` sends its RPC response and the
1397
+ * chat service seals the UI. When the abort primitives are unavailable (older
1398
+ * openclaw), this degrades to a logged no-op: the UI is still sealed by the
1399
+ * chat service's optimistic idle broadcast, but the turn completes server-side.
1400
+ */
1401
+ function handleChatAbort(request, log) {
1402
+ const { sessionKey } = request.params;
1403
+ const targetKeys = resolveAbortTargetKeys(activeRun, sessionKey);
1404
+ let aborted = false;
1405
+ if (abortEmbeddedAgentRun && resolveActiveRunSessionId) {
1406
+ for (const key of targetKeys) {
1407
+ const sessionId = resolveActiveRunSessionId(key);
1408
+ if (sessionId && abortEmbeddedAgentRun(sessionId)) {
1409
+ aborted = true;
1410
+ log.info(`chat.abort: hard-aborted running turn (sessionId=${sessionId}, key=${key})`);
1411
+ break;
1412
+ }
1413
+ }
1414
+ if (!aborted) log.info(`chat.abort: no active run to abort (candidates=${String(targetKeys.length)})`);
1415
+ } else log.warn("chat.abort: OpenClaw abort primitives unavailable — cannot hard-abort the running turn");
1416
+ chatClient?.sendResponse(request.id, true, { aborted });
1148
1417
  }
1149
1418
  async function handleAgentRequest(request, log) {
1150
1419
  const runtime = pluginRuntime;
@@ -1171,6 +1440,43 @@ async function handleAgentRequest(request, log) {
1171
1440
  runId: null,
1172
1441
  sessionKey: null
1173
1442
  };
1443
+ let lastThinkingLen = 0;
1444
+ const turnThinking = {
1445
+ text: "",
1446
+ ts: 0
1447
+ };
1448
+ const turnTools = /* @__PURE__ */ new Map();
1449
+ const recordThinking = (text, ts) => {
1450
+ if (!turnThinking.text) turnThinking.ts = ts;
1451
+ turnThinking.text += text;
1452
+ };
1453
+ const recordTool = (activity, ts) => {
1454
+ const prev = turnTools.get(activity.toolCallId);
1455
+ turnTools.set(activity.toolCallId, {
1456
+ kind: "tool",
1457
+ toolCallId: activity.toolCallId,
1458
+ name: activity.name,
1459
+ status: activity.status === "running" ? prev?.status ?? "interrupted" : activity.status,
1460
+ ...activity.summary ?? prev?.summary ? { summary: activity.summary ?? prev?.summary } : {},
1461
+ ...prev?.argsText ?? activity.argsText ? { argsText: prev?.argsText ?? activity.argsText } : {},
1462
+ ...activity.resultText ?? prev?.resultText ? { resultText: activity.resultText ?? prev?.resultText } : {},
1463
+ ...(activity.isError ?? prev?.isError) !== void 0 ? { isError: activity.isError ?? prev?.isError } : {},
1464
+ ...(activity.durationMs ?? prev?.durationMs) !== void 0 ? { durationMs: activity.durationMs ?? prev?.durationMs } : {},
1465
+ ...activity.truncated ?? prev?.truncated ? { truncated: activity.truncated ?? prev?.truncated } : {},
1466
+ ts: prev?.ts ?? ts
1467
+ });
1468
+ };
1469
+ const flushTurnActivity = async () => {
1470
+ const entries = [];
1471
+ if (turnThinking.text) entries.push({
1472
+ kind: "thinking",
1473
+ text: turnThinking.text,
1474
+ ts: turnThinking.ts
1475
+ });
1476
+ for (const tool of turnTools.values()) entries.push(tool);
1477
+ entries.sort((a, b) => a.ts - b.ts);
1478
+ await appendActivity(sessionId, entries);
1479
+ };
1174
1480
  const UPDATE_THROTTLE_MS = 250;
1175
1481
  const pendingUpdates = /* @__PURE__ */ new Map();
1176
1482
  const flushToolUpdate = (toolCallId) => {
@@ -1187,33 +1493,58 @@ async function handleAgentRequest(request, log) {
1187
1493
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
1188
1494
  if (!admitAgentEvent(runGate, evt)) return;
1189
1495
  if (evt.stream === "assistant") {
1496
+ const raw = joinAssistantText(evt.data);
1497
+ const { visible, thinking } = splitThinkTags(raw);
1498
+ if (thinking.length > lastThinkingLen) {
1499
+ const thinkingDelta = thinking.slice(lastThinkingLen);
1500
+ chatClient?.sendEvent("chat-activity", {
1501
+ runId: evt.runId,
1502
+ sessionKey: legacySessionKey,
1503
+ seq: evt.seq,
1504
+ state: "activity",
1505
+ activity: {
1506
+ kind: "thinking",
1507
+ text: thinkingDelta,
1508
+ delta: true,
1509
+ ts: evt.ts,
1510
+ seq: evt.seq
1511
+ }
1512
+ });
1513
+ recordThinking(thinkingDelta, evt.ts);
1514
+ lastThinkingLen = thinking.length;
1515
+ }
1516
+ const message = visible === raw && thinking.length === 0 ? evt.data : rewriteAssistantText(evt.data, visible);
1190
1517
  chatClient?.sendEvent("chat", {
1191
1518
  runId: evt.runId,
1192
1519
  sessionKey: legacySessionKey,
1193
1520
  seq: evt.seq,
1194
1521
  state: "delta",
1195
- message: evt.data
1522
+ message
1196
1523
  });
1197
1524
  return;
1198
1525
  }
1199
1526
  if (evt.stream === "thinking") {
1200
1527
  const activity = buildThinkingActivity(evt.data);
1201
- if (activity) chatClient?.sendEvent("chat-activity", {
1202
- runId: evt.runId,
1203
- sessionKey: legacySessionKey,
1204
- seq: evt.seq,
1205
- state: "activity",
1206
- activity: {
1207
- ...activity,
1208
- ts: evt.ts,
1209
- seq: evt.seq
1210
- }
1211
- });
1528
+ if (activity) {
1529
+ chatClient?.sendEvent("chat-activity", {
1530
+ runId: evt.runId,
1531
+ sessionKey: legacySessionKey,
1532
+ seq: evt.seq,
1533
+ state: "activity",
1534
+ activity: {
1535
+ ...activity,
1536
+ ts: evt.ts,
1537
+ seq: evt.seq
1538
+ }
1539
+ });
1540
+ if (activity.kind === "thinking") recordThinking(activity.text, evt.ts);
1541
+ }
1212
1542
  return;
1213
1543
  }
1214
1544
  if (evt.stream === "tool") {
1215
1545
  const activity = buildToolActivity(evt.data);
1216
1546
  if (activity?.kind !== "tool") return;
1547
+ recordTool(activity, evt.ts);
1217
1548
  const toolCallId = activity.toolCallId;
1218
1549
  const send = () => {
1219
1550
  chatClient?.sendEvent("chat-activity", {
@@ -1263,19 +1594,37 @@ async function handleAgentRequest(request, log) {
1263
1594
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
1264
1595
  let a2aResponseBuffer = "";
1265
1596
  if (isA2A) resetA2AEndSignal();
1597
+ const peer = conversationType === "group" ? {
1598
+ kind: "group",
1599
+ id: conversationId ?? senderId
1600
+ } : {
1601
+ kind: "direct",
1602
+ id: conversationId ? `${senderId}:conv:${conversationId}` : senderId
1603
+ };
1604
+ let routeSessionKey = null;
1605
+ if (resolveRouteEnvelope) try {
1606
+ routeSessionKey = resolveRouteEnvelope({
1607
+ cfg,
1608
+ channel: "alfe",
1609
+ accountId: "default",
1610
+ peer,
1611
+ runtime: runtime.channel,
1612
+ sessionStore: cfg.session?.store
1613
+ }).route.sessionKey;
1614
+ } catch (err) {
1615
+ log.debug(`Route precompute failed (abort will use raw key): ${err instanceof Error ? err.message : String(err)}`);
1616
+ }
1617
+ activeRun = {
1618
+ requestSessionKey: legacySessionKey,
1619
+ routeSessionKey
1620
+ };
1266
1621
  runGate.sessionKey = (await dispatchInbound({
1267
1622
  cfg,
1268
1623
  runtime: { channel: runtime.channel },
1269
1624
  channel: "alfe",
1270
1625
  channelLabel,
1271
1626
  accountId: "default",
1272
- peer: conversationType === "group" ? {
1273
- kind: "group",
1274
- id: conversationId ?? senderId
1275
- } : {
1276
- kind: "direct",
1277
- id: conversationId ? `${senderId}:conv:${conversationId}` : senderId
1278
- },
1627
+ peer,
1279
1628
  senderId,
1280
1629
  senderAddress: senderId,
1281
1630
  recipientAddress: "agent",
@@ -1309,10 +1658,10 @@ async function handleAgentRequest(request, log) {
1309
1658
  } : {}
1310
1659
  },
1311
1660
  deliver: async (payload) => {
1312
- const responseText = stripLeakedToolSummary(payload.text ?? "");
1661
+ const responseText = stripLeakedToolSummary(stripThinkSpans(payload.text ?? ""));
1313
1662
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
1314
1663
  await addMessage(sessionId, "assistant", responseText);
1315
- if (isA2A) a2aResponseBuffer += responseText;
1664
+ if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
1316
1665
  chatClient?.notify("agent-message", {
1317
1666
  conversationId: conversationId ?? legacySessionKey,
1318
1667
  text: responseText,
@@ -1344,8 +1693,14 @@ async function handleAgentRequest(request, log) {
1344
1693
  log.error(`Agent dispatch failed: ${errMsg}`);
1345
1694
  chatClient?.sendResponse(request.id, false, { message: errMsg });
1346
1695
  } finally {
1696
+ activeRun = null;
1347
1697
  unsubscribe();
1348
1698
  clearAllToolUpdates();
1699
+ try {
1700
+ await flushTurnActivity();
1701
+ } catch (err) {
1702
+ log.error(`Activity persistence failed: ${err instanceof Error ? err.message : String(err)}`);
1703
+ }
1349
1704
  if (isA2A) disarmA2AEndSignal();
1350
1705
  }
1351
1706
  }
@@ -1371,14 +1726,16 @@ async function handleSessionsGet(request, log) {
1371
1726
  if (!session) {
1372
1727
  chatClient?.sendResponse(request.id, true, {
1373
1728
  ok: false,
1374
- error: "Session not found"
1729
+ error: "Session not found",
1730
+ messages: []
1375
1731
  });
1376
1732
  return;
1377
1733
  }
1378
1734
  if (params.userId && session.userId && session.userId !== params.userId) {
1379
1735
  chatClient?.sendResponse(request.id, true, {
1380
1736
  ok: false,
1381
- error: "Session not found"
1737
+ error: "Session not found",
1738
+ messages: []
1382
1739
  });
1383
1740
  return;
1384
1741
  }
@@ -1392,7 +1749,8 @@ async function handleSessionsGet(request, log) {
1392
1749
  role: m.role,
1393
1750
  content: m.content,
1394
1751
  timestamp: m.timestamp
1395
- }))
1752
+ })),
1753
+ activity: (session.activity ?? []).map((a) => ({ ...a }))
1396
1754
  });
1397
1755
  } catch (err) {
1398
1756
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1444,7 +1802,20 @@ const plugin = {
1444
1802
  apiKey,
1445
1803
  onRequest: (request) => {
1446
1804
  const handle = async () => {
1447
- if (request.method === "agent") await enqueueAgentTurn(() => handleAgentRequest(request, log));
1805
+ if (request.method === "agent") {
1806
+ const params = request.params;
1807
+ const coalesceKey = params.coalesce === "replace" && typeof params.sessionKey === "string" ? params.sessionKey : void 0;
1808
+ await enqueueAgentTurn(() => handleAgentRequest(request, log), coalesceKey ? {
1809
+ coalesceKey,
1810
+ onSuperseded: () => {
1811
+ log.info(`Agent turn superseded before start (coalesce=replace): ${coalesceKey}`);
1812
+ chatClient?.sendResponse(request.id, true, {
1813
+ superseded: true,
1814
+ sessionKey: coalesceKey
1815
+ });
1816
+ }
1817
+ } : void 0);
1818
+ } else if (request.method === "chat.abort") handleChatAbort(request, log);
1448
1819
  else if (request.method === "sessions.list") await handleSessionsList(request, log);
1449
1820
  else if (request.method === "sessions.get") await handleSessionsGet(request, log);
1450
1821
  else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
@@ -1500,11 +1871,13 @@ const plugin = {
1500
1871
  const session = await getSession(params.sessionId);
1501
1872
  if (!session) return {
1502
1873
  ok: false,
1503
- error: "Session not found"
1874
+ error: "Session not found",
1875
+ messages: []
1504
1876
  };
1505
1877
  if (params.userId && session.userId && session.userId !== params.userId) return {
1506
1878
  ok: false,
1507
- error: "Session not found"
1879
+ error: "Session not found",
1880
+ messages: []
1508
1881
  };
1509
1882
  return {
1510
1883
  sessionId: session.sessionId,
@@ -1516,7 +1889,8 @@ const plugin = {
1516
1889
  role: m.role,
1517
1890
  content: m.content,
1518
1891
  timestamp: m.timestamp
1519
- }))
1892
+ })),
1893
+ activity: (session.activity ?? []).map((a) => ({ ...a }))
1520
1894
  };
1521
1895
  });
1522
1896
  gw.__alfeChatGatewayMethodsRegistered = true;
@@ -1574,4 +1948,4 @@ const plugin = {
1574
1948
  }
1575
1949
  };
1576
1950
  //#endregion
1577
- export { stripLeakedToolSummary as a, plugin as i, buildA2ACompletePayload as n, createAlfeChannelPlugin as o, buildToolActivity as r, admitAgentEvent as t };
1951
+ export { buildA2ACompletePayload as a, plugin as c, stripLeakedToolSummary as d, createAlfeChannelPlugin as f, admitAgentEvent as i, resolveAbortTargetKeys as l, __resetAgentTurnQueueForTest as n, buildToolActivity as o, __setActiveRunForTest as r, joinAssistantText as s, __agentTurnQueueForTest as t, rewriteAssistantText as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",