@alfe.ai/openclaw-chat 0.1.6 → 0.2.1

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/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlfeResolvedAccount, i as AlfePluginConfig, n as createAlfeChannelPlugin, r as AlfeChannelConfig, t as plugin } from "./plugin.cjs";
1
+ import { a as AlfeChannelConfig, i as createAlfeChannelPlugin, n as plugin, o as AlfePluginConfig, s as AlfeResolvedAccount } from "./plugin.cjs";
2
2
 
3
3
  //#region src/session-store.d.ts
4
4
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlfeResolvedAccount, i as AlfePluginConfig, n as createAlfeChannelPlugin, r as AlfeChannelConfig, t as plugin } from "./plugin.js";
1
+ import { a as AlfeChannelConfig, i as createAlfeChannelPlugin, n as plugin, o as AlfePluginConfig, s as AlfeResolvedAccount } from "./plugin.js";
2
2
 
3
3
  //#region src/session-store.d.ts
4
4
 
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as createAlfeChannelPlugin, t as plugin } from "./plugin2.js";
1
+ import { i as createAlfeChannelPlugin, n as plugin } from "./plugin2.js";
2
2
  export { createAlfeChannelPlugin, plugin as default };
package/dist/plugin.cjs CHANGED
@@ -1,2 +1,8 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
1
5
  const require_plugin = require("./plugin2.cjs");
2
- module.exports = require_plugin.plugin;
6
+ exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
7
+ exports.default = require_plugin.plugin;
8
+ exports.stripLeakedToolSummary = require_plugin.stripLeakedToolSummary;
package/dist/plugin.d.cts CHANGED
@@ -274,6 +274,28 @@ interface PluginServiceContext {
274
274
  stateDir: string;
275
275
  logger: PluginLogger;
276
276
  }
277
+ declare function stripLeakedToolSummary(text: string): string;
278
+ /**
279
+ * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
280
+ * even when the dispatch produced no relayable text — so the chat service can
281
+ * emit an explicit terminal/stop frame instead of the thread dead-ending
282
+ * silently. `terminated`/`reason` are set when there's nothing to relay
283
+ * (`empty_turn`) or the agent ended the thread via end_conversation
284
+ * (`resolved`).
285
+ */
286
+ declare function buildA2ACompletePayload<A>(params: {
287
+ conversationId: string;
288
+ fullText: string;
289
+ a2a: A;
290
+ resolved: boolean;
291
+ }): {
292
+ conversationId: string;
293
+ fullText: string;
294
+ a2a: A;
295
+ resolved: boolean;
296
+ terminated?: boolean;
297
+ reason?: string;
298
+ };
277
299
  interface PluginApi {
278
300
  logger: PluginLogger;
279
301
  registrationMode?: 'full' | 'setup-only' | 'setup-runtime' | 'cli-metadata';
@@ -306,4 +328,4 @@ declare const plugin: {
306
328
  deactivate(api: PluginApi): Promise<void>;
307
329
  };
308
330
  //#endregion
309
- export { AlfeResolvedAccount as a, AlfePluginConfig as i, createAlfeChannelPlugin as n, AlfeChannelConfig as r, plugin as t };
331
+ export { AlfeChannelConfig as a, createAlfeChannelPlugin as i, plugin as n, AlfePluginConfig as o, stripLeakedToolSummary as r, AlfeResolvedAccount as s, buildA2ACompletePayload as t };
package/dist/plugin.d.ts CHANGED
@@ -274,6 +274,28 @@ interface PluginServiceContext {
274
274
  stateDir: string;
275
275
  logger: PluginLogger;
276
276
  }
277
+ declare function stripLeakedToolSummary(text: string): string;
278
+ /**
279
+ * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
280
+ * even when the dispatch produced no relayable text — so the chat service can
281
+ * emit an explicit terminal/stop frame instead of the thread dead-ending
282
+ * silently. `terminated`/`reason` are set when there's nothing to relay
283
+ * (`empty_turn`) or the agent ended the thread via end_conversation
284
+ * (`resolved`).
285
+ */
286
+ declare function buildA2ACompletePayload<A>(params: {
287
+ conversationId: string;
288
+ fullText: string;
289
+ a2a: A;
290
+ resolved: boolean;
291
+ }): {
292
+ conversationId: string;
293
+ fullText: string;
294
+ a2a: A;
295
+ resolved: boolean;
296
+ terminated?: boolean;
297
+ reason?: string;
298
+ };
277
299
  interface PluginApi {
278
300
  logger: PluginLogger;
279
301
  registrationMode?: 'full' | 'setup-only' | 'setup-runtime' | 'cli-metadata';
@@ -306,4 +328,4 @@ declare const plugin: {
306
328
  deactivate(api: PluginApi): Promise<void>;
307
329
  };
308
330
  //#endregion
309
- export { AlfeResolvedAccount as a, AlfePluginConfig as i, createAlfeChannelPlugin as n, AlfeChannelConfig as r, plugin as t };
331
+ export { AlfeChannelConfig as a, createAlfeChannelPlugin as i, plugin as n, AlfePluginConfig as o, stripLeakedToolSummary as r, AlfeResolvedAccount as s, buildA2ACompletePayload as t };
package/dist/plugin.js CHANGED
@@ -1,2 +1,2 @@
1
- import { t as plugin } from "./plugin2.js";
2
- export { plugin as default };
1
+ import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin2.js";
2
+ export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.cjs CHANGED
@@ -382,6 +382,31 @@ async function listSessions(filters, limit = 50) {
382
382
  }
383
383
  //#endregion
384
384
  //#region src/a2a-tools.ts
385
+ let conversationEndRequested = false;
386
+ let a2aTurnArmed = false;
387
+ /**
388
+ * Clear the end signal AND arm the gate. Call immediately before an A2A
389
+ * dispatch. Arming is what permits `end_conversation` to set the flag for the
390
+ * duration of this turn.
391
+ */
392
+ function resetA2AEndSignal() {
393
+ conversationEndRequested = false;
394
+ a2aTurnArmed = true;
395
+ }
396
+ /**
397
+ * Clear the end signal AND disarm the gate. Call after an A2A dispatch (in a
398
+ * `finally`, so it also runs on error). Disarming closes the gate so a later
399
+ * NON-A2A turn invoking `end_conversation` can't set a flag the next A2A turn
400
+ * would read.
401
+ */
402
+ function disarmA2AEndSignal() {
403
+ conversationEndRequested = false;
404
+ a2aTurnArmed = false;
405
+ }
406
+ /** True if `end_conversation` was invoked since the last reset. */
407
+ function isA2AEndSignalled() {
408
+ return conversationEndRequested;
409
+ }
385
410
  function ok(result) {
386
411
  return { content: [{
387
412
  type: "text",
@@ -478,6 +503,7 @@ function buildA2ATools(chatClient, log) {
478
503
  handler: (params) => {
479
504
  const summary = params.summary;
480
505
  log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
506
+ if (a2aTurnArmed) conversationEndRequested = true;
481
507
  return Promise.resolve({
482
508
  status: "conversation_ended",
483
509
  summary
@@ -604,6 +630,99 @@ function validateAttachmentUrl(input, opts = {}) {
604
630
  */
605
631
  const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
606
632
  const pkg = require$1("../package.json");
633
+ function asString(v) {
634
+ return typeof v === "string" && v.length > 0 ? v : void 0;
635
+ }
636
+ /**
637
+ * Extract a thinking delta from an OpenClaw `thinking`-stream event.
638
+ * OpenClaw thinking events carry the reasoning text on one of a few
639
+ * fields depending on version; we accept `text` / `delta` / `content`.
640
+ * Returns null when there's nothing to forward.
641
+ */
642
+ function buildThinkingActivity(data) {
643
+ const text = asString(data.delta) ?? asString(data.text) ?? asString(data.content) ?? asString(data.message?.text);
644
+ if (!text) return null;
645
+ return {
646
+ kind: "thinking",
647
+ text,
648
+ delta: true
649
+ };
650
+ }
651
+ /**
652
+ * Extract structured tool status from an OpenClaw `tool`-stream event.
653
+ * We send the emoji-free raw `name` plus a lifecycle `status` and let the
654
+ * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
655
+ * `(+N steps)` string. `summary` carries OpenClaw's short detail when the
656
+ * event already provides one (title/summary/label/description), trimmed.
657
+ */
658
+ function buildToolActivity(data) {
659
+ const toolCallId = asString(data.toolCallId) ?? asString(data.id) ?? asString(data.callId);
660
+ const name = asString(data.name) ?? asString(data.tool) ?? asString(data.toolName);
661
+ if (!toolCallId || !name) return null;
662
+ const phase = asString(data.phase) ?? asString(data.state) ?? asString(data.status);
663
+ const rawStatus = (phase ?? "").toLowerCase();
664
+ let status;
665
+ if (rawStatus === "error" || rawStatus === "failed" || data.error != null || data.isError === true) status = "failed";
666
+ else if (rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
667
+ else status = "running";
668
+ const summaryRaw = asString(data.summary) ?? asString(data.title) ?? asString(data.label) ?? asString(data.description);
669
+ const summary = summaryRaw ? summaryRaw.slice(0, 200) : void 0;
670
+ return {
671
+ kind: "tool",
672
+ toolCallId,
673
+ name,
674
+ ...phase ? { phase } : {},
675
+ status,
676
+ ...summary ? { summary } : {}
677
+ };
678
+ }
679
+ /**
680
+ * Workstream C, item 5: strip OpenClaw's leaked tool-summary console line
681
+ * from assistant `responseText` before it's stored / notified as a chat
682
+ * bubble. OpenClaw upstream composes a `⚠️ 🛠️ … (+N steps) failed` summary
683
+ * into the assistant text for a non-zero-exit tool run; `failed` there is a
684
+ * TOOL exit status, not a chat-level failure, and it now has a proper
685
+ * structured tool row (see buildToolActivity). This is a BOUNDED, clearly
686
+ * scoped strip of that one known pattern — it must not touch normal prose.
687
+ *
688
+ * Pattern anatomy (per the plan + observed string):
689
+ * ⚠️ 🛠️ <pipeline text> (+8 steps) failed
690
+ * ⚠️ 🛠️ <pipeline text> (+1 step) failed
691
+ * We match a line that LEADS with OpenClaw's warning/tool emoji (`⚠️` or
692
+ * `🛠️`) — REQUIRED, because OpenClaw always generates the leaked line with
693
+ * it — and contains the `(+<N> step[s])` marker followed by a trailing
694
+ * `failed`/`succeeded`/`done` word. The emoji requirement keeps legitimate
695
+ * assistant prose that merely ends in "(+3 steps) done." from being
696
+ * stripped. Only whole matching lines are removed; everything else is
697
+ * intact. Per-line, line-anchored, non-global.
698
+ */
699
+ const LEAKED_TOOL_SUMMARY_LINE = /^\s*(?:⚠️|🛠️).*\(\+\d+\s+steps?\)\s*(?:failed|succeeded|done)\.?\s*$/iu;
700
+ function stripLeakedToolSummary(text) {
701
+ if (!text.includes("step")) return text;
702
+ return text.split("\n").filter((line) => !LEAKED_TOOL_SUMMARY_LINE.test(line)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
703
+ }
704
+ /**
705
+ * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
706
+ * even when the dispatch produced no relayable text — so the chat service can
707
+ * emit an explicit terminal/stop frame instead of the thread dead-ending
708
+ * silently. `terminated`/`reason` are set when there's nothing to relay
709
+ * (`empty_turn`) or the agent ended the thread via end_conversation
710
+ * (`resolved`).
711
+ */
712
+ function buildA2ACompletePayload(params) {
713
+ const hasText = params.fullText.trim().length > 0;
714
+ const terminal = params.resolved || !hasText;
715
+ return {
716
+ conversationId: params.conversationId,
717
+ fullText: params.fullText,
718
+ a2a: params.a2a,
719
+ resolved: params.resolved,
720
+ ...terminal ? {
721
+ terminated: true,
722
+ reason: params.resolved ? "resolved" : "empty_turn"
723
+ } : {}
724
+ };
725
+ }
607
726
  let dispatchInbound = null;
608
727
  /**
609
728
  * Resolve OpenClaw SDK functions from the running process.
@@ -707,6 +826,12 @@ async function downloadAttachments(attachments, log) {
707
826
  }
708
827
  return results;
709
828
  }
829
+ let agentTurnQueue = Promise.resolve();
830
+ function enqueueAgentTurn(task) {
831
+ const run = agentTurnQueue.then(task, task);
832
+ agentTurnQueue = run.then(() => void 0, () => void 0);
833
+ return run;
834
+ }
710
835
  async function handleAgentRequest(request, log) {
711
836
  const runtime = pluginRuntime;
712
837
  if (!runtime) {
@@ -731,16 +856,48 @@ async function handleAgentRequest(request, log) {
731
856
  let resolvedOpenClawKey = null;
732
857
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
733
858
  if (!evt.sessionKey) return;
734
- if (evt.stream !== "assistant") return;
735
- resolvedOpenClawKey ??= evt.sessionKey;
859
+ if (evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool") resolvedOpenClawKey ??= evt.sessionKey;
736
860
  if (evt.sessionKey !== resolvedOpenClawKey) return;
737
- chatClient?.sendEvent("chat", {
738
- runId: evt.runId,
739
- sessionKey: legacySessionKey,
740
- seq: evt.seq,
741
- state: "delta",
742
- message: evt.data
743
- });
861
+ if (evt.stream === "assistant") {
862
+ chatClient?.sendEvent("chat", {
863
+ runId: evt.runId,
864
+ sessionKey: legacySessionKey,
865
+ seq: evt.seq,
866
+ state: "delta",
867
+ message: evt.data
868
+ });
869
+ return;
870
+ }
871
+ if (evt.stream === "thinking") {
872
+ const activity = buildThinkingActivity(evt.data);
873
+ if (activity) chatClient?.sendEvent("chat-activity", {
874
+ runId: evt.runId,
875
+ sessionKey: legacySessionKey,
876
+ seq: evt.seq,
877
+ state: "activity",
878
+ activity: {
879
+ ...activity,
880
+ ts: evt.ts,
881
+ seq: evt.seq
882
+ }
883
+ });
884
+ return;
885
+ }
886
+ if (evt.stream === "tool") {
887
+ const activity = buildToolActivity(evt.data);
888
+ if (activity) chatClient?.sendEvent("chat-activity", {
889
+ runId: evt.runId,
890
+ sessionKey: legacySessionKey,
891
+ seq: evt.seq,
892
+ state: "activity",
893
+ activity: {
894
+ ...activity,
895
+ ts: evt.ts,
896
+ seq: evt.seq
897
+ }
898
+ });
899
+ return;
900
+ }
744
901
  });
745
902
  try {
746
903
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
@@ -756,7 +913,7 @@ async function handleAgentRequest(request, log) {
756
913
  const userLabel = displayName ?? userId ?? senderId;
757
914
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
758
915
  let a2aResponseBuffer = "";
759
- let a2aResolved = false;
916
+ if (isA2A) resetA2AEndSignal();
760
917
  resolvedOpenClawKey = (await dispatchInbound({
761
918
  cfg,
762
919
  runtime: { channel: runtime.channel },
@@ -796,19 +953,17 @@ async function handleAgentRequest(request, log) {
796
953
  `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
797
954
  "Rules:",
798
955
  "- Only respond if you have new information, a question, or an action to coordinate.",
799
- "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
956
+ "- When the discussion is complete, call the end_conversation() tool AND end your final message with the literal token [RESOLVED] (uppercase, in square brackets, as the last thing in the message).",
957
+ "- Do NOT write the word \"resolved\" in prose unless you mean to end — only the exact token [RESOLVED] ends the thread.",
800
958
  "- Do NOT respond just to acknowledge — that creates infinite loops."
801
959
  ].join("\n")
802
960
  } : {}
803
961
  },
804
962
  deliver: async (payload) => {
805
- const responseText = payload.text ?? "";
963
+ const responseText = stripLeakedToolSummary(payload.text ?? "");
806
964
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
807
965
  await addMessage(sessionId, "assistant", responseText);
808
- if (isA2A) {
809
- a2aResponseBuffer += responseText;
810
- if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
811
- }
966
+ if (isA2A) a2aResponseBuffer += responseText;
812
967
  chatClient?.notify("agent-message", {
813
968
  conversationId: conversationId ?? legacySessionKey,
814
969
  text: responseText,
@@ -824,12 +979,15 @@ async function handleAgentRequest(request, log) {
824
979
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
825
980
  }
826
981
  })).route.sessionKey;
827
- if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
828
- conversationId: conversationId ?? legacySessionKey,
829
- fullText: a2aResponseBuffer,
830
- a2a,
831
- resolved: a2aResolved
832
- });
982
+ if (isA2A) {
983
+ const a2aResolved = isA2AEndSignalled();
984
+ chatClient?.notify("a2a-complete", buildA2ACompletePayload({
985
+ conversationId: conversationId ?? legacySessionKey,
986
+ fullText: a2aResponseBuffer,
987
+ a2a,
988
+ resolved: a2aResolved
989
+ }));
990
+ }
833
991
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
834
992
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
835
993
  } catch (err) {
@@ -838,6 +996,7 @@ async function handleAgentRequest(request, log) {
838
996
  chatClient?.sendResponse(request.id, false, { message: errMsg });
839
997
  } finally {
840
998
  unsubscribe();
999
+ if (isA2A) disarmA2AEndSignal();
841
1000
  }
842
1001
  }
843
1002
  async function handleSessionsList(request, log) {
@@ -930,7 +1089,7 @@ const plugin = {
930
1089
  apiKey,
931
1090
  onRequest: (request) => {
932
1091
  const handle = async () => {
933
- if (request.method === "agent") await handleAgentRequest(request, log);
1092
+ if (request.method === "agent") await enqueueAgentTurn(() => handleAgentRequest(request, log));
934
1093
  else if (request.method === "sessions.list") await handleSessionsList(request, log);
935
1094
  else if (request.method === "sessions.get") await handleSessionsGet(request, log);
936
1095
  else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
@@ -1065,6 +1224,12 @@ const plugin = {
1065
1224
  }
1066
1225
  };
1067
1226
  //#endregion
1227
+ Object.defineProperty(exports, "buildA2ACompletePayload", {
1228
+ enumerable: true,
1229
+ get: function() {
1230
+ return buildA2ACompletePayload;
1231
+ }
1232
+ });
1068
1233
  Object.defineProperty(exports, "createAlfeChannelPlugin", {
1069
1234
  enumerable: true,
1070
1235
  get: function() {
@@ -1077,3 +1242,9 @@ Object.defineProperty(exports, "plugin", {
1077
1242
  return plugin;
1078
1243
  }
1079
1244
  });
1245
+ Object.defineProperty(exports, "stripLeakedToolSummary", {
1246
+ enumerable: true,
1247
+ get: function() {
1248
+ return stripLeakedToolSummary;
1249
+ }
1250
+ });
@@ -1,2 +1,2 @@
1
- import { t as plugin } from "./plugin.cjs";
2
- export { plugin as default };
1
+ import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin.cjs";
2
+ export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { t as plugin } from "./plugin.js";
2
- export { plugin as default };
1
+ import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin.js";
2
+ export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.js CHANGED
@@ -382,6 +382,31 @@ async function listSessions(filters, limit = 50) {
382
382
  }
383
383
  //#endregion
384
384
  //#region src/a2a-tools.ts
385
+ let conversationEndRequested = false;
386
+ let a2aTurnArmed = false;
387
+ /**
388
+ * Clear the end signal AND arm the gate. Call immediately before an A2A
389
+ * dispatch. Arming is what permits `end_conversation` to set the flag for the
390
+ * duration of this turn.
391
+ */
392
+ function resetA2AEndSignal() {
393
+ conversationEndRequested = false;
394
+ a2aTurnArmed = true;
395
+ }
396
+ /**
397
+ * Clear the end signal AND disarm the gate. Call after an A2A dispatch (in a
398
+ * `finally`, so it also runs on error). Disarming closes the gate so a later
399
+ * NON-A2A turn invoking `end_conversation` can't set a flag the next A2A turn
400
+ * would read.
401
+ */
402
+ function disarmA2AEndSignal() {
403
+ conversationEndRequested = false;
404
+ a2aTurnArmed = false;
405
+ }
406
+ /** True if `end_conversation` was invoked since the last reset. */
407
+ function isA2AEndSignalled() {
408
+ return conversationEndRequested;
409
+ }
385
410
  function ok(result) {
386
411
  return { content: [{
387
412
  type: "text",
@@ -478,6 +503,7 @@ function buildA2ATools(chatClient, log) {
478
503
  handler: (params) => {
479
504
  const summary = params.summary;
480
505
  log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
506
+ if (a2aTurnArmed) conversationEndRequested = true;
481
507
  return Promise.resolve({
482
508
  status: "conversation_ended",
483
509
  summary
@@ -604,6 +630,99 @@ function validateAttachmentUrl(input, opts = {}) {
604
630
  */
605
631
  const require = createRequire(import.meta.url);
606
632
  const pkg = require("../package.json");
633
+ function asString(v) {
634
+ return typeof v === "string" && v.length > 0 ? v : void 0;
635
+ }
636
+ /**
637
+ * Extract a thinking delta from an OpenClaw `thinking`-stream event.
638
+ * OpenClaw thinking events carry the reasoning text on one of a few
639
+ * fields depending on version; we accept `text` / `delta` / `content`.
640
+ * Returns null when there's nothing to forward.
641
+ */
642
+ function buildThinkingActivity(data) {
643
+ const text = asString(data.delta) ?? asString(data.text) ?? asString(data.content) ?? asString(data.message?.text);
644
+ if (!text) return null;
645
+ return {
646
+ kind: "thinking",
647
+ text,
648
+ delta: true
649
+ };
650
+ }
651
+ /**
652
+ * Extract structured tool status from an OpenClaw `tool`-stream event.
653
+ * We send the emoji-free raw `name` plus a lifecycle `status` and let the
654
+ * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
655
+ * `(+N steps)` string. `summary` carries OpenClaw's short detail when the
656
+ * event already provides one (title/summary/label/description), trimmed.
657
+ */
658
+ function buildToolActivity(data) {
659
+ const toolCallId = asString(data.toolCallId) ?? asString(data.id) ?? asString(data.callId);
660
+ const name = asString(data.name) ?? asString(data.tool) ?? asString(data.toolName);
661
+ if (!toolCallId || !name) return null;
662
+ const phase = asString(data.phase) ?? asString(data.state) ?? asString(data.status);
663
+ const rawStatus = (phase ?? "").toLowerCase();
664
+ let status;
665
+ if (rawStatus === "error" || rawStatus === "failed" || data.error != null || data.isError === true) status = "failed";
666
+ else if (rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
667
+ else status = "running";
668
+ const summaryRaw = asString(data.summary) ?? asString(data.title) ?? asString(data.label) ?? asString(data.description);
669
+ const summary = summaryRaw ? summaryRaw.slice(0, 200) : void 0;
670
+ return {
671
+ kind: "tool",
672
+ toolCallId,
673
+ name,
674
+ ...phase ? { phase } : {},
675
+ status,
676
+ ...summary ? { summary } : {}
677
+ };
678
+ }
679
+ /**
680
+ * Workstream C, item 5: strip OpenClaw's leaked tool-summary console line
681
+ * from assistant `responseText` before it's stored / notified as a chat
682
+ * bubble. OpenClaw upstream composes a `⚠️ 🛠️ … (+N steps) failed` summary
683
+ * into the assistant text for a non-zero-exit tool run; `failed` there is a
684
+ * TOOL exit status, not a chat-level failure, and it now has a proper
685
+ * structured tool row (see buildToolActivity). This is a BOUNDED, clearly
686
+ * scoped strip of that one known pattern — it must not touch normal prose.
687
+ *
688
+ * Pattern anatomy (per the plan + observed string):
689
+ * ⚠️ 🛠️ <pipeline text> (+8 steps) failed
690
+ * ⚠️ 🛠️ <pipeline text> (+1 step) failed
691
+ * We match a line that LEADS with OpenClaw's warning/tool emoji (`⚠️` or
692
+ * `🛠️`) — REQUIRED, because OpenClaw always generates the leaked line with
693
+ * it — and contains the `(+<N> step[s])` marker followed by a trailing
694
+ * `failed`/`succeeded`/`done` word. The emoji requirement keeps legitimate
695
+ * assistant prose that merely ends in "(+3 steps) done." from being
696
+ * stripped. Only whole matching lines are removed; everything else is
697
+ * intact. Per-line, line-anchored, non-global.
698
+ */
699
+ const LEAKED_TOOL_SUMMARY_LINE = /^\s*(?:⚠️|🛠️).*\(\+\d+\s+steps?\)\s*(?:failed|succeeded|done)\.?\s*$/iu;
700
+ function stripLeakedToolSummary(text) {
701
+ if (!text.includes("step")) return text;
702
+ return text.split("\n").filter((line) => !LEAKED_TOOL_SUMMARY_LINE.test(line)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
703
+ }
704
+ /**
705
+ * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
706
+ * even when the dispatch produced no relayable text — so the chat service can
707
+ * emit an explicit terminal/stop frame instead of the thread dead-ending
708
+ * silently. `terminated`/`reason` are set when there's nothing to relay
709
+ * (`empty_turn`) or the agent ended the thread via end_conversation
710
+ * (`resolved`).
711
+ */
712
+ function buildA2ACompletePayload(params) {
713
+ const hasText = params.fullText.trim().length > 0;
714
+ const terminal = params.resolved || !hasText;
715
+ return {
716
+ conversationId: params.conversationId,
717
+ fullText: params.fullText,
718
+ a2a: params.a2a,
719
+ resolved: params.resolved,
720
+ ...terminal ? {
721
+ terminated: true,
722
+ reason: params.resolved ? "resolved" : "empty_turn"
723
+ } : {}
724
+ };
725
+ }
607
726
  let dispatchInbound = null;
608
727
  /**
609
728
  * Resolve OpenClaw SDK functions from the running process.
@@ -707,6 +826,12 @@ async function downloadAttachments(attachments, log) {
707
826
  }
708
827
  return results;
709
828
  }
829
+ let agentTurnQueue = Promise.resolve();
830
+ function enqueueAgentTurn(task) {
831
+ const run = agentTurnQueue.then(task, task);
832
+ agentTurnQueue = run.then(() => void 0, () => void 0);
833
+ return run;
834
+ }
710
835
  async function handleAgentRequest(request, log) {
711
836
  const runtime = pluginRuntime;
712
837
  if (!runtime) {
@@ -731,16 +856,48 @@ async function handleAgentRequest(request, log) {
731
856
  let resolvedOpenClawKey = null;
732
857
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
733
858
  if (!evt.sessionKey) return;
734
- if (evt.stream !== "assistant") return;
735
- resolvedOpenClawKey ??= evt.sessionKey;
859
+ if (evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool") resolvedOpenClawKey ??= evt.sessionKey;
736
860
  if (evt.sessionKey !== resolvedOpenClawKey) return;
737
- chatClient?.sendEvent("chat", {
738
- runId: evt.runId,
739
- sessionKey: legacySessionKey,
740
- seq: evt.seq,
741
- state: "delta",
742
- message: evt.data
743
- });
861
+ if (evt.stream === "assistant") {
862
+ chatClient?.sendEvent("chat", {
863
+ runId: evt.runId,
864
+ sessionKey: legacySessionKey,
865
+ seq: evt.seq,
866
+ state: "delta",
867
+ message: evt.data
868
+ });
869
+ return;
870
+ }
871
+ if (evt.stream === "thinking") {
872
+ const activity = buildThinkingActivity(evt.data);
873
+ if (activity) chatClient?.sendEvent("chat-activity", {
874
+ runId: evt.runId,
875
+ sessionKey: legacySessionKey,
876
+ seq: evt.seq,
877
+ state: "activity",
878
+ activity: {
879
+ ...activity,
880
+ ts: evt.ts,
881
+ seq: evt.seq
882
+ }
883
+ });
884
+ return;
885
+ }
886
+ if (evt.stream === "tool") {
887
+ const activity = buildToolActivity(evt.data);
888
+ if (activity) chatClient?.sendEvent("chat-activity", {
889
+ runId: evt.runId,
890
+ sessionKey: legacySessionKey,
891
+ seq: evt.seq,
892
+ state: "activity",
893
+ activity: {
894
+ ...activity,
895
+ ts: evt.ts,
896
+ seq: evt.seq
897
+ }
898
+ });
899
+ return;
900
+ }
744
901
  });
745
902
  try {
746
903
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
@@ -756,7 +913,7 @@ async function handleAgentRequest(request, log) {
756
913
  const userLabel = displayName ?? userId ?? senderId;
757
914
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
758
915
  let a2aResponseBuffer = "";
759
- let a2aResolved = false;
916
+ if (isA2A) resetA2AEndSignal();
760
917
  resolvedOpenClawKey = (await dispatchInbound({
761
918
  cfg,
762
919
  runtime: { channel: runtime.channel },
@@ -796,19 +953,17 @@ async function handleAgentRequest(request, log) {
796
953
  `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
797
954
  "Rules:",
798
955
  "- Only respond if you have new information, a question, or an action to coordinate.",
799
- "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
956
+ "- When the discussion is complete, call the end_conversation() tool AND end your final message with the literal token [RESOLVED] (uppercase, in square brackets, as the last thing in the message).",
957
+ "- Do NOT write the word \"resolved\" in prose unless you mean to end — only the exact token [RESOLVED] ends the thread.",
800
958
  "- Do NOT respond just to acknowledge — that creates infinite loops."
801
959
  ].join("\n")
802
960
  } : {}
803
961
  },
804
962
  deliver: async (payload) => {
805
- const responseText = payload.text ?? "";
963
+ const responseText = stripLeakedToolSummary(payload.text ?? "");
806
964
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
807
965
  await addMessage(sessionId, "assistant", responseText);
808
- if (isA2A) {
809
- a2aResponseBuffer += responseText;
810
- if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
811
- }
966
+ if (isA2A) a2aResponseBuffer += responseText;
812
967
  chatClient?.notify("agent-message", {
813
968
  conversationId: conversationId ?? legacySessionKey,
814
969
  text: responseText,
@@ -824,12 +979,15 @@ async function handleAgentRequest(request, log) {
824
979
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
825
980
  }
826
981
  })).route.sessionKey;
827
- if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
828
- conversationId: conversationId ?? legacySessionKey,
829
- fullText: a2aResponseBuffer,
830
- a2a,
831
- resolved: a2aResolved
832
- });
982
+ if (isA2A) {
983
+ const a2aResolved = isA2AEndSignalled();
984
+ chatClient?.notify("a2a-complete", buildA2ACompletePayload({
985
+ conversationId: conversationId ?? legacySessionKey,
986
+ fullText: a2aResponseBuffer,
987
+ a2a,
988
+ resolved: a2aResolved
989
+ }));
990
+ }
833
991
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
834
992
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
835
993
  } catch (err) {
@@ -838,6 +996,7 @@ async function handleAgentRequest(request, log) {
838
996
  chatClient?.sendResponse(request.id, false, { message: errMsg });
839
997
  } finally {
840
998
  unsubscribe();
999
+ if (isA2A) disarmA2AEndSignal();
841
1000
  }
842
1001
  }
843
1002
  async function handleSessionsList(request, log) {
@@ -930,7 +1089,7 @@ const plugin = {
930
1089
  apiKey,
931
1090
  onRequest: (request) => {
932
1091
  const handle = async () => {
933
- if (request.method === "agent") await handleAgentRequest(request, log);
1092
+ if (request.method === "agent") await enqueueAgentTurn(() => handleAgentRequest(request, log));
934
1093
  else if (request.method === "sessions.list") await handleSessionsList(request, log);
935
1094
  else if (request.method === "sessions.get") await handleSessionsGet(request, log);
936
1095
  else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
@@ -1065,4 +1224,4 @@ const plugin = {
1065
1224
  }
1066
1225
  };
1067
1226
  //#endregion
1068
- export { createAlfeChannelPlugin as n, plugin as t };
1227
+ export { createAlfeChannelPlugin as i, plugin as n, stripLeakedToolSummary as r, buildA2ACompletePayload as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -27,8 +27,8 @@
27
27
  "openclaw.plugin.json"
28
28
  ],
29
29
  "dependencies": {
30
- "@alfe.ai/agent-api-client": "^0.2.0",
31
- "@alfe.ai/chat": "^0.0.9",
30
+ "@alfe.ai/agent-api-client": "^0.2.2",
31
+ "@alfe.ai/chat": "^0.0.10",
32
32
  "@alfe.ai/config": "^0.0.9"
33
33
  },
34
34
  "peerDependencies": {