@testchimp/cli 0.1.55 → 0.1.57

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.
@@ -23,6 +23,34 @@ const STREAM_POST_MIN_INTERVAL_MS = 150;
23
23
  function isStreamFanoutRole(role) {
24
24
  return role === ROLE_ASSISTANT || role === ROLE_TOOL || role === ROLE_REASONING;
25
25
  }
26
+ /**
27
+ * OpenCode sometimes emits the same thought as both a reasoning part and a text part.
28
+ * Skip ASSISTANT fanout when content matches (or is a streaming prefix of) reasoning.
29
+ */
30
+ function isTextDuplicateOfReasoning(text, reasoningBodies) {
31
+ const a = String(text || "").trim();
32
+ if (!a)
33
+ return false;
34
+ for (const raw of reasoningBodies) {
35
+ const r = String(raw || "").trim();
36
+ if (!r)
37
+ continue;
38
+ if (a === r)
39
+ return true;
40
+ if (a.length >= 32 && r.length >= 32 && (r.startsWith(a) || a.startsWith(r))) {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ function reasoningBodiesFromPartMap(textByPartId) {
47
+ const out = [];
48
+ for (const [key, val] of textByPartId) {
49
+ if (key.startsWith("reasoning:"))
50
+ out.push(val);
51
+ }
52
+ return out;
53
+ }
26
54
  const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. You run on GitHub Actions, but this chat is an **interactive** conversation with the user in the TestChimp UI — same expectations as Cursor/Claude Code locally.
27
55
 
28
56
  ## Interactive session (mandatory — default)
@@ -386,9 +414,16 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
386
414
  for (const msg of turnSlice) {
387
415
  if ((msg.info?.role || "").toLowerCase() !== "assistant")
388
416
  continue;
417
+ const reasoningBodies = (msg.parts || [])
418
+ .filter((p) => p.type === "reasoning" && p.text?.trim())
419
+ .map((p) => p.text.trim());
389
420
  for (const part of msg.parts || []) {
390
421
  if (part.type === "text" && part.text?.trim()) {
391
- postEvent(ROLE_ASSISTANT, part.text.trim(), {
422
+ const text = part.text.trim();
423
+ if (isTextDuplicateOfReasoning(text, reasoningBodies)) {
424
+ continue;
425
+ }
426
+ postEvent(ROLE_ASSISTANT, text, {
392
427
  messageId: opencodeMessageId("oc_text_", part),
393
428
  });
394
429
  posted += 1;
@@ -498,6 +533,9 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
498
533
  if (field === "text") {
499
534
  const next = (textByPartId.get(partId) || "") + props.delta;
500
535
  textByPartId.set(partId, next);
536
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
537
+ return;
538
+ }
501
539
  callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
502
540
  messageId: `oc_text_${partId}`,
503
541
  }));
@@ -537,6 +575,9 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
537
575
  textByPartId.set(partId, next);
538
576
  if (!next)
539
577
  return;
578
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
579
+ return;
580
+ }
540
581
  callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
541
582
  messageId: opencodeMessageId("oc_text_", part),
542
583
  }));
@@ -712,6 +753,35 @@ function isMissingOpencodeSessionError(message) {
712
753
  m.includes("unknown session") ||
713
754
  m.includes("invalid session"));
714
755
  }
756
+ /** Strip one layer of wrapping quotes models sometimes add when echoing. */
757
+ function stripOuterQuotes(text) {
758
+ const a = String(text || "").trim();
759
+ if ((a.startsWith('"') && a.endsWith('"')) ||
760
+ (a.startsWith("'") && a.endsWith("'"))) {
761
+ return a.slice(1, -1).trim();
762
+ }
763
+ return a;
764
+ }
765
+ /**
766
+ * True when assistant text is an echo of a prompt we just sent to OpenCode
767
+ * (raw user text and/or host-wrapped effectivePrompt) — exact or streaming prefix.
768
+ */
769
+ function isAssistantEchoOfSentPrompt(assistant, ...sentPrompts) {
770
+ const a = stripOuterQuotes(assistant);
771
+ if (!a)
772
+ return false;
773
+ for (const raw of sentPrompts) {
774
+ const p = String(raw || "").trim();
775
+ if (!p)
776
+ continue;
777
+ if (a === p)
778
+ return true;
779
+ // Streaming echo of the (usually long) wrapped prompt: only when clearly a prefix.
780
+ if (p.length >= 64 && a.length >= 24 && p.startsWith(a))
781
+ return true;
782
+ }
783
+ return false;
784
+ }
715
785
  function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
716
786
  const parts = [];
717
787
  if (workingBranch?.trim()) {
@@ -1018,7 +1088,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1018
1088
  return;
1019
1089
  const partId = ev.part?.id || ev.part?.messageID;
1020
1090
  if (!partId) {
1021
- callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
1091
+ if (!isTextDuplicateOfReasoning(chunk, reasoningBodiesFromPartMap(textByPartId))) {
1092
+ callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
1093
+ }
1022
1094
  return;
1023
1095
  }
1024
1096
  // Without attach, --format json may emit completed cumulative or deltas.
@@ -1030,6 +1102,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1030
1102
  ? chunk
1031
1103
  : prev + chunk;
1032
1104
  textByPartId.set(partId, next);
1105
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
1106
+ return;
1107
+ }
1033
1108
  callbacks.postEvent(ROLE_ASSISTANT, next, {
1034
1109
  throttle: true,
1035
1110
  messageId: opencodeMessageId("oc_text_", ev.part),
@@ -1404,6 +1479,16 @@ export async function runChimphands(opts) {
1404
1479
  }
1405
1480
  poster.fireAndForget(role, content, bodyOpts);
1406
1481
  };
1482
+ /**
1483
+ * Drop ASSISTANT bubbles that echo the prompt we just sent — either the raw
1484
+ * user text or the host-wrapped effectivePrompt (exact string OpenCode got).
1485
+ */
1486
+ const postEventForTurn = (userPrompt, wrappedPrompt, role, content, opts) => {
1487
+ if (role === ROLE_ASSISTANT && isAssistantEchoOfSentPrompt(content, userPrompt, wrappedPrompt)) {
1488
+ return;
1489
+ }
1490
+ postEvent(role, content, opts);
1491
+ };
1407
1492
  const complete = (status, errorMessage) => {
1408
1493
  const body = { sessionId, status };
1409
1494
  if (errorMessage)
@@ -1502,6 +1587,7 @@ export async function runChimphands(opts) {
1502
1587
  let useOpencodeSessionId = opencodeSessionId;
1503
1588
  let isNewOpencodeSession = !useOpencodeSessionId;
1504
1589
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1590
+ const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, effectivePrompt, role, content, opts);
1505
1591
  // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1506
1592
  // part completes — without this the UI looks empty while the turn is running.
1507
1593
  postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
@@ -1514,7 +1600,7 @@ export async function runChimphands(opts) {
1514
1600
  }).catch(() => { });
1515
1601
  },
1516
1602
  onWorkingBranch: noteWorkingBranch,
1517
- postEvent,
1603
+ postEvent: turnPostEvent,
1518
1604
  }, attachUrl);
1519
1605
  if (result.code !== 0 &&
1520
1606
  useOpencodeSessionId &&
@@ -1532,7 +1618,7 @@ export async function runChimphands(opts) {
1532
1618
  }).catch(() => { });
1533
1619
  },
1534
1620
  onWorkingBranch: noteWorkingBranch,
1535
- postEvent,
1621
+ postEvent: turnPostEvent,
1536
1622
  }, attachUrl);
1537
1623
  }
1538
1624
  await poster.flush();
@@ -1545,7 +1631,7 @@ export async function runChimphands(opts) {
1545
1631
  if (attachUrl && opencodeSessionId) {
1546
1632
  try {
1547
1633
  await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
1548
- void poster.enqueue(role, content, {
1634
+ turnPostEvent(role, content, {
1549
1635
  ...opts,
1550
1636
  opencodeSessionId,
1551
1637
  durable: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage, API operations (calls /api/mcp/*)",
5
5
  "type": "module",
6
6
  "main": "dist/bin/testchimp.js",