@botcord/daemon 0.2.46 → 0.2.47

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.
@@ -601,10 +601,22 @@ function normalizeAssistantText(text) {
601
601
  if (!finalMatch && selected.trimStart().toLowerCase().startsWith("<think")) {
602
602
  return "";
603
603
  }
604
- return selected
604
+ return stripLeadingBoundaryResidue(selected
605
605
  .replace(/<think[^>]*>[\s\S]*?<\/think>/gi, "")
606
606
  .replace(/<\/?final>/gi, "")
607
- .trim();
607
+ .trim());
608
+ }
609
+ function stripLeadingBoundaryResidue(text) {
610
+ if (!text.startsWith("<"))
611
+ return text;
612
+ // Keep real HTML/XML-ish tags and common comparison operators. A lone
613
+ // leading "<" before normal prose can be left behind when ACP streams a
614
+ // structural marker boundary separately from the final assistant text.
615
+ if (/^<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s|>|\/>)/.test(text))
616
+ return text;
617
+ if (/^<(?:\s|=|<)/.test(text))
618
+ return text;
619
+ return text.slice(1).trimStart();
608
620
  }
609
621
  function createAssistantTextFilter() {
610
622
  let pending = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -265,6 +265,88 @@ describe("OpenclawAcpAdapter.run", () => {
265
265
  );
266
266
  });
267
267
 
268
+ it("strips a lone leading ACP boundary marker from final prompt text", async () => {
269
+ const child = new FakeChild();
270
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
271
+ const gateway: ResolvedOpenclawGateway = {
272
+ name: "local",
273
+ url: "ws://127.0.0.1:1",
274
+ openclawAgent: "main",
275
+ };
276
+
277
+ child.stdin.on("data", (chunk: Buffer) => {
278
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
279
+ const frame = JSON.parse(line);
280
+ if (frame.method === "initialize") {
281
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
282
+ } else if (frame.method === "session/new") {
283
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-boundary" } }) + "\n");
284
+ } else if (frame.method === "session/prompt") {
285
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "<你好!终于可以正常交流了。" } }) + "\n");
286
+ }
287
+ }
288
+ });
289
+
290
+ const res = await adapter.run({
291
+ text: "hi",
292
+ sessionId: null,
293
+ cwd: "/tmp",
294
+ accountId: "ag_alice",
295
+ signal: new AbortController().signal,
296
+ trustLevel: "owner",
297
+ gateway,
298
+ });
299
+
300
+ expect(res.text).toBe("你好!终于可以正常交流了。");
301
+ });
302
+
303
+ it("strips a lone leading ACP boundary marker from streamed fallback text", async () => {
304
+ const child = new FakeChild();
305
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
306
+ const gateway: ResolvedOpenclawGateway = {
307
+ name: "local",
308
+ url: "ws://127.0.0.1:1",
309
+ openclawAgent: "main",
310
+ };
311
+
312
+ child.stdin.on("data", (chunk: Buffer) => {
313
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
314
+ const frame = JSON.parse(line);
315
+ if (frame.method === "initialize") {
316
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
317
+ } else if (frame.method === "session/new") {
318
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-stream-boundary" } }) + "\n");
319
+ } else if (frame.method === "session/prompt") {
320
+ for (const text of ["<", "好!终于可以正常交流了。"]) {
321
+ child.stdout.write(
322
+ JSON.stringify({
323
+ jsonrpc: "2.0",
324
+ method: "session/update",
325
+ params: {
326
+ sessionId: "sid-stream-boundary",
327
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
328
+ },
329
+ }) + "\n",
330
+ );
331
+ }
332
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { stopReason: "end_turn" } }) + "\n");
333
+ }
334
+ }
335
+ });
336
+
337
+ const res = await adapter.run({
338
+ text: "hi",
339
+ sessionId: null,
340
+ cwd: "/tmp",
341
+ accountId: "ag_alice",
342
+ signal: new AbortController().signal,
343
+ trustLevel: "owner",
344
+ gateway,
345
+ });
346
+
347
+ expect(res.text).toBe("好!终于可以正常交流了。");
348
+ });
349
+
268
350
  it("respawns the pooled child when gateway.url or gateway.token changes under the same name", async () => {
269
351
  function newChild(): FakeChild {
270
352
  const c = new FakeChild();
@@ -709,10 +709,20 @@ function normalizeAssistantText(text: string | undefined): string {
709
709
  if (!finalMatch && selected.trimStart().toLowerCase().startsWith("<think")) {
710
710
  return "";
711
711
  }
712
- return selected
712
+ return stripLeadingBoundaryResidue(selected
713
713
  .replace(/<think[^>]*>[\s\S]*?<\/think>/gi, "")
714
714
  .replace(/<\/?final>/gi, "")
715
- .trim();
715
+ .trim());
716
+ }
717
+
718
+ function stripLeadingBoundaryResidue(text: string): string {
719
+ if (!text.startsWith("<")) return text;
720
+ // Keep real HTML/XML-ish tags and common comparison operators. A lone
721
+ // leading "<" before normal prose can be left behind when ACP streams a
722
+ // structural marker boundary separately from the final assistant text.
723
+ if (/^<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s|>|\/>)/.test(text)) return text;
724
+ if (/^<(?:\s|=|<)/.test(text)) return text;
725
+ return text.slice(1).trimStart();
716
726
  }
717
727
 
718
728
  function createAssistantTextFilter(): {