@testchimp/cli 0.1.54 → 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)
@@ -140,19 +168,19 @@ class AgentEventPoster {
140
168
  body.pullRequestUrl = opts.pullRequestUrl;
141
169
  const streamRole = isStreamFanoutRole(role);
142
170
  this.chain = this.chain.then(async () => {
143
- // Mid-turn live tokens only while UI is attached — async runs skip FS fanout
144
- // and rely on turn-end reconcile for durable PG.
145
- if (opts?.liveStream && !this.uiAttached) {
171
+ const live = !!opts?.liveStream && !opts?.durable;
172
+ // Mid-turn tokens never hit PG. Async / detached: drop. Attached: ephemeral only.
173
+ if (live && !this.uiAttached) {
146
174
  return;
147
175
  }
148
- if (opts?.throttle || (streamRole && opts?.liveStream)) {
176
+ if (opts?.throttle || (streamRole && live)) {
149
177
  const now = Date.now();
150
178
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
151
179
  if (wait > 0)
152
180
  await sleep(wait);
153
181
  this.lastStreamPostAt = Date.now();
154
182
  }
155
- const tryEphemeral = streamRole && this.uiAttached;
183
+ const tryEphemeral = live && streamRole && this.uiAttached;
156
184
  if (tryEphemeral) {
157
185
  const eph = {
158
186
  sessionId: this.sessionId,
@@ -164,35 +192,14 @@ class AgentEventPoster {
164
192
  if (opts?.opencodeSessionId)
165
193
  eph.opencodeSessionId = opts.opencodeSessionId;
166
194
  try {
167
- const text = await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
195
+ await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
168
196
  this.ephemeralFailCount = 0;
169
- let delivered = false;
170
- try {
171
- const parsed = JSON.parse(text);
172
- delivered = !!parsed.delivered;
173
- }
174
- catch {
175
- /* ignore */
176
- }
177
- if (delivered)
178
- return;
179
- // Mid-turn liveStream must not fall through to durable — that would write
180
- // every token to PG. Turn-end reconcile persists the final transcript.
181
- if (opts?.liveStream) {
182
- this.logEphemeralIssue("ephemeral not delivered (replica miss?) — skipping durable for liveStream");
183
- return;
184
- }
185
- // Completed/non-live: durable fallback for cross-replica UI.
186
- console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
187
197
  }
188
198
  catch (err) {
189
199
  const detail = err instanceof Error ? err.message : String(err);
190
- if (opts?.liveStream) {
191
- this.logEphemeralIssue(`ephemeral post failed — skipping durable for liveStream: ${detail}`);
192
- return;
193
- }
194
- console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
200
+ this.logEphemeralIssue(`ephemeral post failed — not persisting liveStream: ${detail}`);
195
201
  }
202
+ return;
196
203
  }
197
204
  await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
198
205
  });
@@ -407,9 +414,16 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
407
414
  for (const msg of turnSlice) {
408
415
  if ((msg.info?.role || "").toLowerCase() !== "assistant")
409
416
  continue;
417
+ const reasoningBodies = (msg.parts || [])
418
+ .filter((p) => p.type === "reasoning" && p.text?.trim())
419
+ .map((p) => p.text.trim());
410
420
  for (const part of msg.parts || []) {
411
421
  if (part.type === "text" && part.text?.trim()) {
412
- 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, {
413
427
  messageId: opencodeMessageId("oc_text_", part),
414
428
  });
415
429
  posted += 1;
@@ -519,6 +533,9 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
519
533
  if (field === "text") {
520
534
  const next = (textByPartId.get(partId) || "") + props.delta;
521
535
  textByPartId.set(partId, next);
536
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
537
+ return;
538
+ }
522
539
  callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
523
540
  messageId: `oc_text_${partId}`,
524
541
  }));
@@ -558,6 +575,9 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
558
575
  textByPartId.set(partId, next);
559
576
  if (!next)
560
577
  return;
578
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
579
+ return;
580
+ }
561
581
  callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
562
582
  messageId: opencodeMessageId("oc_text_", part),
563
583
  }));
@@ -1039,7 +1059,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1039
1059
  return;
1040
1060
  const partId = ev.part?.id || ev.part?.messageID;
1041
1061
  if (!partId) {
1042
- callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
1062
+ if (!isTextDuplicateOfReasoning(chunk, reasoningBodiesFromPartMap(textByPartId))) {
1063
+ callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
1064
+ }
1043
1065
  return;
1044
1066
  }
1045
1067
  // Without attach, --format json may emit completed cumulative or deltas.
@@ -1051,6 +1073,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1051
1073
  ? chunk
1052
1074
  : prev + chunk;
1053
1075
  textByPartId.set(partId, next);
1076
+ if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
1077
+ return;
1078
+ }
1054
1079
  callbacks.postEvent(ROLE_ASSISTANT, next, {
1055
1080
  throttle: true,
1056
1081
  messageId: opencodeMessageId("oc_text_", ev.part),
@@ -1425,6 +1450,22 @@ export async function runChimphands(opts) {
1425
1450
  }
1426
1451
  poster.fireAndForget(role, content, bodyOpts);
1427
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
+ };
1428
1469
  const complete = (status, errorMessage) => {
1429
1470
  const body = { sessionId, status };
1430
1471
  if (errorMessage)
@@ -1523,6 +1564,7 @@ export async function runChimphands(opts) {
1523
1564
  let useOpencodeSessionId = opencodeSessionId;
1524
1565
  let isNewOpencodeSession = !useOpencodeSessionId;
1525
1566
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1567
+ const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, role, content, opts);
1526
1568
  // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1527
1569
  // part completes — without this the UI looks empty while the turn is running.
1528
1570
  postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
@@ -1535,7 +1577,7 @@ export async function runChimphands(opts) {
1535
1577
  }).catch(() => { });
1536
1578
  },
1537
1579
  onWorkingBranch: noteWorkingBranch,
1538
- postEvent,
1580
+ postEvent: turnPostEvent,
1539
1581
  }, attachUrl);
1540
1582
  if (result.code !== 0 &&
1541
1583
  useOpencodeSessionId &&
@@ -1553,7 +1595,7 @@ export async function runChimphands(opts) {
1553
1595
  }).catch(() => { });
1554
1596
  },
1555
1597
  onWorkingBranch: noteWorkingBranch,
1556
- postEvent,
1598
+ postEvent: turnPostEvent,
1557
1599
  }, attachUrl);
1558
1600
  }
1559
1601
  await poster.flush();
@@ -1566,10 +1608,10 @@ export async function runChimphands(opts) {
1566
1608
  if (attachUrl && opencodeSessionId) {
1567
1609
  try {
1568
1610
  await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
1569
- void poster.enqueue(role, content, {
1611
+ turnPostEvent(role, content, {
1570
1612
  ...opts,
1571
1613
  opencodeSessionId,
1572
- // Explicitly not liveStream → always durable post_agent_event.
1614
+ durable: true,
1573
1615
  });
1574
1616
  }, noteWorkingBranch);
1575
1617
  await poster.flush();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.54",
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",