@botcord/daemon 0.2.44 → 0.2.46

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.
@@ -280,6 +280,13 @@ export class OpenclawAcpAdapter {
280
280
  if (capped) {
281
281
  log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
282
282
  }
283
+ if (!finalText) {
284
+ const stopReason = pickStopReason(promptResult);
285
+ const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
286
+ const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
287
+ const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
288
+ return failResult(acpSessionId, `openclaw-acp: ${reason}${detail}`);
289
+ }
283
290
  return {
284
291
  text: finalText,
285
292
  newSessionId: acpSessionId,
@@ -359,6 +366,7 @@ export class OpenclawAcpAdapter {
359
366
  subscribers: new Map(),
360
367
  nextId: 1,
361
368
  buffer: "",
369
+ nonJsonStdoutTail: [],
362
370
  initialized: false,
363
371
  inFlight: 0,
364
372
  closed: false,
@@ -431,6 +439,10 @@ function onStdoutChunk(handle, chunk) {
431
439
  msg = JSON.parse(line);
432
440
  }
433
441
  catch (err) {
442
+ handle.nonJsonStdoutTail.push(line.slice(0, 500));
443
+ if (handle.nonJsonStdoutTail.length > 20) {
444
+ handle.nonJsonStdoutTail.splice(0, handle.nonJsonStdoutTail.length - 20);
445
+ }
434
446
  log.warn("openclaw-acp.parse-error", {
435
447
  error: err instanceof Error ? err.message : String(err),
436
448
  line: line.slice(0, 200),
@@ -744,6 +756,15 @@ function pickFinalText(result) {
744
756
  return r.message;
745
757
  return undefined;
746
758
  }
759
+ function pickStopReason(result) {
760
+ if (!result || typeof result !== "object")
761
+ return undefined;
762
+ const v = result.stopReason;
763
+ return typeof v === "string" && v.length > 0 ? v : undefined;
764
+ }
765
+ function truncateDetail(text, max) {
766
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
767
+ }
747
768
  function looksLikeReasoningLeak(text) {
748
769
  const t = text.trim();
749
770
  if (!t)
package/dist/turn-text.js CHANGED
@@ -5,15 +5,18 @@ const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly ment
5
5
  const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
6
6
  'reply with exactly "NO_REPLY" and nothing else.]';
7
7
  /**
8
- * Reminder appended to every wrapped (non-owner-chat) inbound message. The
9
- * dispatcher discards `result.text` for any room that is not `rm_oc_*`, so
10
- * the agent must call the `botcord_send` tool (or the `botcord send` CLI
11
- * via Bash) to actually deliver a reply. Plain assistant text in those
12
- * rooms is logged and dropped.
8
+ * Reminder appended to wrapped BotCord network rooms that are not owner-chat.
9
+ * The dispatcher discards `result.text` for those rooms, so the agent must
10
+ * call the `botcord_send` tool (or the `botcord send` CLI via Bash) to
11
+ * actually deliver a reply. Plain assistant text in those rooms is logged
12
+ * and dropped.
13
13
  */
14
14
  const NON_OWNER_REPLY_HINT = "[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
15
15
  "To reply, call the `botcord_send` tool, or run " +
16
16
  '`botcord send --room <room_id> --text "..."` via Bash.]';
17
+ const THIRD_PARTY_REPLY_HINT = "[This is a third-party gateway chat. Reply normally in your final assistant " +
18
+ "message; BotCord daemon will deliver that text through the same channel. " +
19
+ "No extra send tool is required for this chat.]";
17
20
  /**
18
21
  * Read the BotCord envelope type from a raw inbound message. Returns
19
22
  * `undefined` when the message didn't come from the BotCord channel or the
@@ -28,6 +31,15 @@ function readEnvelopeType(raw) {
28
31
  const t = env.type;
29
32
  return typeof t === "string" ? t : undefined;
30
33
  }
34
+ function isThirdPartyConversation(conversationId) {
35
+ return (conversationId.startsWith("telegram:") ||
36
+ conversationId.startsWith("wechat:"));
37
+ }
38
+ function replyDeliveryHint(msg) {
39
+ return isThirdPartyConversation(msg.conversation.id)
40
+ ? THIRD_PARTY_REPLY_HINT
41
+ : NON_OWNER_REPLY_HINT;
42
+ }
31
43
  /**
32
44
  * Read the `raw.batch` array emitted by the BotCord channel when inbox
33
45
  * drain groups multiple messages for the same `(room, topic)`. Returns the
@@ -127,7 +139,7 @@ export function composeBotCordUserTurn(msg) {
127
139
  "",
128
140
  hint,
129
141
  "",
130
- NON_OWNER_REPLY_HINT,
142
+ replyDeliveryHint(msg),
131
143
  ];
132
144
  if (contactRequestHint) {
133
145
  lines.push("", contactRequestHint);
@@ -178,7 +190,7 @@ function composeBatchedTurn(msg, batch) {
178
190
  "",
179
191
  hint,
180
192
  "",
181
- NON_OWNER_REPLY_HINT,
193
+ replyDeliveryHint(msg),
182
194
  ];
183
195
  if (contactRequestSenders.length > 0) {
184
196
  // Dedup + list — multiple distinct senders show as "A, B".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.44",
3
+ "version": "0.2.46",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -162,6 +162,46 @@ describe("OpenclawAcpAdapter.run", () => {
162
162
  expect(spawnFn.mock.calls[0][1]).toEqual(["acp", "--url", "ws://127.0.0.1:1"]);
163
163
  });
164
164
 
165
+ it("returns an error instead of empty text when OpenClaw emits warnings and no assistant text", async () => {
166
+ const child = new FakeChild();
167
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
168
+ const gateway: ResolvedOpenclawGateway = {
169
+ name: "local",
170
+ url: "ws://127.0.0.1:1",
171
+ openclawAgent: "main",
172
+ };
173
+
174
+ child.stdin.on("data", (chunk: Buffer) => {
175
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
176
+ const frame = JSON.parse(line);
177
+ if (frame.method === "initialize") {
178
+ child.stdout.write("◇ Config warnings ─────────────────────╮\n");
179
+ child.stdout.write("│ - models.providers.foo.apiKey: Missing env var FOO_API_KEY │\n");
180
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
181
+ } else if (frame.method === "session/new") {
182
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-warn" } }) + "\n");
183
+ } else if (frame.method === "session/prompt") {
184
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { stopReason: "error" } }) + "\n");
185
+ }
186
+ }
187
+ });
188
+
189
+ const res = await adapter.run({
190
+ text: "hi",
191
+ sessionId: null,
192
+ cwd: "/tmp",
193
+ accountId: "ag_alice",
194
+ signal: new AbortController().signal,
195
+ trustLevel: "owner",
196
+ gateway,
197
+ });
198
+
199
+ expect(res.text).toBe("");
200
+ expect(res.newSessionId).toBe("sid-warn");
201
+ expect(res.error).toContain("prompt stopped: error");
202
+ expect(res.error).toContain("Missing env var FOO_API_KEY");
203
+ });
204
+
165
205
  it("streams only final text when OpenClaw sends reasoning before a final block", async () => {
166
206
  const child = new FakeChild();
167
207
  const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
@@ -68,6 +68,43 @@ describe("composeBotCordUserTurn", () => {
68
68
  expect(out).not.toContain("do NOT reply unless");
69
69
  });
70
70
 
71
+ it("keeps the botcord_send delivery hint for non-owner BotCord rooms", () => {
72
+ const out = composeBotCordUserTurn(
73
+ makeMessage({
74
+ conversation: { id: "rm_dm_xxx", kind: "direct" },
75
+ sender: { id: "ag_peer", kind: "agent" },
76
+ }),
77
+ );
78
+ expect(out).toContain("Plain text output WILL NOT be sent");
79
+ expect(out).toContain("botcord_send");
80
+ });
81
+
82
+ it("does not tell Telegram chats to use botcord_send", () => {
83
+ const out = composeBotCordUserTurn(
84
+ makeMessage({
85
+ channel: "gw_telegram_123",
86
+ conversation: { id: "telegram:user:7904063707", kind: "direct" },
87
+ sender: { id: "telegram:user:7904063707", name: "danny_aaas", kind: "user" },
88
+ }),
89
+ );
90
+ expect(out).toContain("third-party gateway chat");
91
+ expect(out).toContain("Reply normally in your final assistant message");
92
+ expect(out).not.toContain("Plain text output WILL NOT be sent");
93
+ expect(out).not.toContain("botcord_send");
94
+ });
95
+
96
+ it("does not tell WeChat chats to use botcord_send", () => {
97
+ const out = composeBotCordUserTurn(
98
+ makeMessage({
99
+ channel: "gw_wechat_123",
100
+ conversation: { id: "wechat:user:wxl_alice", kind: "direct" },
101
+ sender: { id: "wechat:user:wxl_alice", name: "Alice", kind: "user" },
102
+ }),
103
+ );
104
+ expect(out).toContain("third-party gateway chat");
105
+ expect(out).not.toContain("botcord_send");
106
+ });
107
+
71
108
  it("passes owner-chat messages through verbatim (no wrapper, no hint)", () => {
72
109
  const out = composeBotCordUserTurn(
73
110
  makeMessage({
@@ -37,6 +37,7 @@ interface AcpProcessHandle {
37
37
  subscribers: Map<string, (note: AcpNotification) => void>;
38
38
  nextId: number;
39
39
  buffer: string;
40
+ nonJsonStdoutTail: string[];
40
41
  initialized: boolean;
41
42
  initializePromise?: Promise<void>;
42
43
  idleTimer?: NodeJS.Timeout;
@@ -355,6 +356,14 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
355
356
  log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
356
357
  }
357
358
 
359
+ if (!finalText) {
360
+ const stopReason = pickStopReason(promptResult);
361
+ const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
362
+ const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
363
+ const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
364
+ return failResult(acpSessionId, `openclaw-acp: ${reason}${detail}`);
365
+ }
366
+
358
367
  return {
359
368
  text: finalText,
360
369
  newSessionId: acpSessionId,
@@ -447,6 +456,7 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
447
456
  subscribers: new Map(),
448
457
  nextId: 1,
449
458
  buffer: "",
459
+ nonJsonStdoutTail: [],
450
460
  initialized: false,
451
461
  inFlight: 0,
452
462
  closed: false,
@@ -533,6 +543,10 @@ function onStdoutChunk(handle: AcpProcessHandle, chunk: string): void {
533
543
  try {
534
544
  msg = JSON.parse(line);
535
545
  } catch (err) {
546
+ handle.nonJsonStdoutTail.push(line.slice(0, 500));
547
+ if (handle.nonJsonStdoutTail.length > 20) {
548
+ handle.nonJsonStdoutTail.splice(0, handle.nonJsonStdoutTail.length - 20);
549
+ }
536
550
  log.warn("openclaw-acp.parse-error", {
537
551
  error: err instanceof Error ? err.message : String(err),
538
552
  line: line.slice(0, 200),
@@ -847,6 +861,16 @@ function pickFinalText(result: unknown): string | undefined {
847
861
  return undefined;
848
862
  }
849
863
 
864
+ function pickStopReason(result: unknown): string | undefined {
865
+ if (!result || typeof result !== "object") return undefined;
866
+ const v = (result as Record<string, unknown>).stopReason;
867
+ return typeof v === "string" && v.length > 0 ? v : undefined;
868
+ }
869
+
870
+ function truncateDetail(text: string, max: number): string {
871
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
872
+ }
873
+
850
874
  function looksLikeReasoningLeak(text: string): boolean {
851
875
  const t = text.trim();
852
876
  if (!t) return false;
package/src/turn-text.ts CHANGED
@@ -35,16 +35,20 @@ const DIRECT_HINT =
35
35
  'reply with exactly "NO_REPLY" and nothing else.]';
36
36
 
37
37
  /**
38
- * Reminder appended to every wrapped (non-owner-chat) inbound message. The
39
- * dispatcher discards `result.text` for any room that is not `rm_oc_*`, so
40
- * the agent must call the `botcord_send` tool (or the `botcord send` CLI
41
- * via Bash) to actually deliver a reply. Plain assistant text in those
42
- * rooms is logged and dropped.
38
+ * Reminder appended to wrapped BotCord network rooms that are not owner-chat.
39
+ * The dispatcher discards `result.text` for those rooms, so the agent must
40
+ * call the `botcord_send` tool (or the `botcord send` CLI via Bash) to
41
+ * actually deliver a reply. Plain assistant text in those rooms is logged
42
+ * and dropped.
43
43
  */
44
44
  const NON_OWNER_REPLY_HINT =
45
45
  "[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
46
46
  "To reply, call the `botcord_send` tool, or run " +
47
47
  '`botcord send --room <room_id> --text "..."` via Bash.]';
48
+ const THIRD_PARTY_REPLY_HINT =
49
+ "[This is a third-party gateway chat. Reply normally in your final assistant " +
50
+ "message; BotCord daemon will deliver that text through the same channel. " +
51
+ "No extra send tool is required for this chat.]";
48
52
 
49
53
  /**
50
54
  * Read the BotCord envelope type from a raw inbound message. Returns
@@ -59,6 +63,19 @@ function readEnvelopeType(raw: unknown): string | undefined {
59
63
  return typeof t === "string" ? t : undefined;
60
64
  }
61
65
 
66
+ function isThirdPartyConversation(conversationId: string): boolean {
67
+ return (
68
+ conversationId.startsWith("telegram:") ||
69
+ conversationId.startsWith("wechat:")
70
+ );
71
+ }
72
+
73
+ function replyDeliveryHint(msg: GatewayInboundMessage): string {
74
+ return isThirdPartyConversation(msg.conversation.id)
75
+ ? THIRD_PARTY_REPLY_HINT
76
+ : NON_OWNER_REPLY_HINT;
77
+ }
78
+
62
79
  /** Minimal shape of one batched inbound entry. Matches the BotCord channel
63
80
  * `BatchedInboxRaw.batch[]` elements but expressed structurally so the
64
81
  * composer doesn't import channel internals. */
@@ -182,7 +199,7 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
182
199
  "",
183
200
  hint,
184
201
  "",
185
- NON_OWNER_REPLY_HINT,
202
+ replyDeliveryHint(msg),
186
203
  ];
187
204
  if (contactRequestHint) {
188
205
  lines.push("", contactRequestHint);
@@ -243,7 +260,7 @@ function composeBatchedTurn(
243
260
  "",
244
261
  hint,
245
262
  "",
246
- NON_OWNER_REPLY_HINT,
263
+ replyDeliveryHint(msg),
247
264
  ];
248
265
 
249
266
  if (contactRequestSenders.length > 0) {