@testchimp/cli 0.1.55 → 0.1.56

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
  }));
@@ -1018,7 +1059,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1018
1059
  return;
1019
1060
  const partId = ev.part?.id || ev.part?.messageID;
1020
1061
  if (!partId) {
1021
- callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
1062
+ if (!isTextDuplicateOfReasoning(chunk, reasoningBodiesFromPartMap(textByPartId))) {
1063
+ callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
1064
+ }
1022
1065
  return;
1023
1066
  }
1024
1067
  // Without attach, --format json may emit completed cumulative or deltas.
@@ -1030,6 +1073,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1030
1073
  ? chunk
1031
1074
  : prev + chunk;
1032
1075
  textByPartId.set(partId, next);
1076
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
1077
+ return;
1078
+ }
1033
1079
  callbacks.postEvent(ROLE_ASSISTANT, next, {
1034
1080
  throttle: true,
1035
1081
  messageId: opencodeMessageId("oc_text_", ev.part),
@@ -1404,6 +1450,22 @@ export async function runChimphands(opts) {
1404
1450
  }
1405
1451
  poster.fireAndForget(role, content, bodyOpts);
1406
1452
  };
1453
+ /** Drop assistant bubbles that are just the current user prompt echoed (often quoted). */
1454
+ const postEventForTurn = (userPrompt, role, content, opts) => {
1455
+ if (role === ROLE_ASSISTANT) {
1456
+ const a = String(content || "").trim();
1457
+ const u = normalizeUserMessage(userPrompt);
1458
+ if (a && u) {
1459
+ const unquoted = (a.startsWith('"') && a.endsWith('"')) || (a.startsWith("'") && a.endsWith("'"))
1460
+ ? a.slice(1, -1).trim()
1461
+ : a;
1462
+ if (a === u || unquoted === u) {
1463
+ return;
1464
+ }
1465
+ }
1466
+ }
1467
+ postEvent(role, content, opts);
1468
+ };
1407
1469
  const complete = (status, errorMessage) => {
1408
1470
  const body = { sessionId, status };
1409
1471
  if (errorMessage)
@@ -1502,6 +1564,7 @@ export async function runChimphands(opts) {
1502
1564
  let useOpencodeSessionId = opencodeSessionId;
1503
1565
  let isNewOpencodeSession = !useOpencodeSessionId;
1504
1566
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1567
+ const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, role, content, opts);
1505
1568
  // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1506
1569
  // part completes — without this the UI looks empty while the turn is running.
1507
1570
  postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
@@ -1514,7 +1577,7 @@ export async function runChimphands(opts) {
1514
1577
  }).catch(() => { });
1515
1578
  },
1516
1579
  onWorkingBranch: noteWorkingBranch,
1517
- postEvent,
1580
+ postEvent: turnPostEvent,
1518
1581
  }, attachUrl);
1519
1582
  if (result.code !== 0 &&
1520
1583
  useOpencodeSessionId &&
@@ -1532,7 +1595,7 @@ export async function runChimphands(opts) {
1532
1595
  }).catch(() => { });
1533
1596
  },
1534
1597
  onWorkingBranch: noteWorkingBranch,
1535
- postEvent,
1598
+ postEvent: turnPostEvent,
1536
1599
  }, attachUrl);
1537
1600
  }
1538
1601
  await poster.flush();
@@ -1545,7 +1608,7 @@ export async function runChimphands(opts) {
1545
1608
  if (attachUrl && opencodeSessionId) {
1546
1609
  try {
1547
1610
  await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
1548
- void poster.enqueue(role, content, {
1611
+ turnPostEvent(role, content, {
1549
1612
  ...opts,
1550
1613
  opencodeSessionId,
1551
1614
  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.56",
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",