@testchimp/cli 0.1.56 → 0.1.58

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,30 @@ function extractOpencodeFatalError(raw) {
280
280
  }
281
281
  return null;
282
282
  }
283
+ /**
284
+ * OpenCode `message.part.delta` always sends `field: "text"` for both reasoning and
285
+ * answer parts. Resolve kind from partID → type learned via `message.part.updated`.
286
+ */
287
+ function resolveDeltaPartKind(partId, field, partType, partTypeById) {
288
+ if (partType) {
289
+ partTypeById.set(partId, partType);
290
+ }
291
+ const known = partTypeById.get(partId);
292
+ if (known === "reasoning" || partType === "reasoning") {
293
+ partTypeById.set(partId, "reasoning");
294
+ return "reasoning";
295
+ }
296
+ if (known === "text" || partType === "text") {
297
+ partTypeById.set(partId, "text");
298
+ return "text";
299
+ }
300
+ // Unknown part: field is unreliable (reasoning deltas also use field "text").
301
+ if (field === "reasoning") {
302
+ partTypeById.set(partId, "reasoning");
303
+ return "reasoning";
304
+ }
305
+ return "text";
306
+ }
283
307
  function parseOpencodeEvent(line) {
284
308
  try {
285
309
  return JSON.parse(line);
@@ -292,8 +316,11 @@ function parseOpencodeEvent(line) {
292
316
  * Newer OpenCode `--format json` lines often use the SSE bus shape
293
317
  * (`message.part.updated` + `properties.part`) instead of legacy `type: "text"`.
294
318
  * Normalize both into the same OpencodeEvent used by the stdout switch.
319
+ *
320
+ * `partTypeById` must be shared across lines: deltas use `field: "text"` for
321
+ * reasoning parts too — type comes from earlier `message.part.updated`.
295
322
  */
296
- function normalizeStdoutOpencodeEvent(raw) {
323
+ function normalizeStdoutOpencodeEvent(raw, partTypeById) {
297
324
  if (!raw || typeof raw !== "object")
298
325
  return null;
299
326
  const o = raw;
@@ -312,18 +339,25 @@ function normalizeStdoutOpencodeEvent(raw) {
312
339
  const deltaProps = props;
313
340
  const part = deltaProps.part;
314
341
  if (part) {
315
- const partType = part.type || deltaProps.field || "";
342
+ const partId = part.id || part.messageID || deltaProps.partID || "";
343
+ const kind = partId
344
+ ? resolveDeltaPartKind(partId, deltaProps.field, part.type, partTypeById)
345
+ : part.type === "reasoning"
346
+ ? "reasoning"
347
+ : "text";
316
348
  let mapped;
317
- if (partType === "text")
349
+ if (kind === "text" || part.type === "text")
318
350
  mapped = "text";
319
- else if (partType === "reasoning")
351
+ else if (kind === "reasoning" || part.type === "reasoning")
320
352
  mapped = "reasoning";
321
- else if (partType === "tool")
353
+ else if (part.type === "tool")
322
354
  mapped = "tool_use";
323
355
  else
324
356
  return null;
325
357
  if (deltaProps.delta && !part.text)
326
358
  part.text = deltaProps.delta;
359
+ if (mapped === "reasoning")
360
+ part.type = "reasoning";
327
361
  return {
328
362
  type: mapped,
329
363
  sessionID: part.sessionID || deltaProps.sessionID,
@@ -331,22 +365,29 @@ function normalizeStdoutOpencodeEvent(raw) {
331
365
  };
332
366
  }
333
367
  const partID = deltaProps.partID;
334
- const field = deltaProps.field || "text";
335
368
  const delta = deltaProps.delta;
336
369
  if (!partID || delta == null || delta === "")
337
370
  return null;
338
- if (field !== "text" && field !== "reasoning")
371
+ if (deltaProps.field && deltaProps.field !== "text" && deltaProps.field !== "reasoning") {
372
+ return null;
373
+ }
374
+ const kind = resolveDeltaPartKind(partID, deltaProps.field, undefined, partTypeById);
375
+ if (kind !== "text" && kind !== "reasoning")
339
376
  return null;
340
377
  return {
341
- type: field === "reasoning" ? "reasoning" : "text",
378
+ type: kind,
342
379
  sessionID: deltaProps.sessionID,
343
- part: { id: partID, type: field, text: delta },
380
+ part: { id: partID, type: kind, text: delta },
344
381
  };
345
382
  }
346
383
  if (type === "message.part.updated") {
347
384
  const part = props.part;
348
385
  if (!part)
349
386
  return null;
387
+ const partId = part.id || part.messageID;
388
+ if (partId && part.type) {
389
+ partTypeById.set(partId, part.type);
390
+ }
350
391
  const partType = part.type || "";
351
392
  let mapped;
352
393
  if (partType === "text")
@@ -487,6 +528,8 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
487
528
  const ac = new AbortController();
488
529
  let stopped = false;
489
530
  const textByPartId = new Map();
531
+ /** partID → "text" | "reasoning" | … from message.part.updated (deltas lie about field). */
532
+ const partTypeById = new Map();
490
533
  const directory = process.cwd();
491
534
  const sessionMatches = (sessionId) => {
492
535
  const active = callbacks.getActiveSessionId();
@@ -519,18 +562,27 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
519
562
  const ev = unwrapped;
520
563
  const type = ev.type || "";
521
564
  const props = ev.properties || {};
522
- // Token stream: { partID, field, delta } — often no `part` object.
565
+ // Token stream: { partID, field, delta } — field is usually "text" even for reasoning.
523
566
  if (type === "message.part.delta") {
524
567
  const part = props.part;
525
568
  const partId = props.partID || part?.id || part?.messageID;
526
- const field = props.field || part?.type || "text";
527
569
  const sessionId = part?.sessionID || props.sessionID;
528
570
  if (!sessionMatches(sessionId))
529
571
  return;
530
572
  callbacks.noteSessionId(sessionId);
531
573
  if (!partId || props.delta == null || props.delta === "")
532
574
  return;
533
- if (field === "text") {
575
+ const kind = resolveDeltaPartKind(partId, props.field, part?.type, partTypeById);
576
+ if (kind === "reasoning") {
577
+ const key = `reasoning:${partId}`;
578
+ const next = (textByPartId.get(key) || "") + props.delta;
579
+ textByPartId.set(key, next);
580
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
581
+ messageId: `oc_reasoning_${partId}`,
582
+ }));
583
+ return;
584
+ }
585
+ if (kind === "text") {
534
586
  const next = (textByPartId.get(partId) || "") + props.delta;
535
587
  textByPartId.set(partId, next);
536
588
  if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
@@ -539,15 +591,6 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
539
591
  callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
540
592
  messageId: `oc_text_${partId}`,
541
593
  }));
542
- return;
543
- }
544
- if (field === "reasoning") {
545
- const key = `reasoning:${partId}`;
546
- const next = (textByPartId.get(key) || "") + props.delta;
547
- textByPartId.set(key, next);
548
- callbacks.postEvent(ROLE_REASONING, next, liveOpts({
549
- messageId: `oc_reasoning_${partId}`,
550
- }));
551
594
  }
552
595
  return;
553
596
  }
@@ -559,8 +602,11 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
559
602
  if (!sessionMatches(sessionId))
560
603
  return;
561
604
  callbacks.noteSessionId(sessionId);
605
+ const partId = part.id || part.messageID;
606
+ if (partId && part.type) {
607
+ partTypeById.set(partId, part.type);
608
+ }
562
609
  if (part.type === "text") {
563
- const partId = part.id || part.messageID;
564
610
  if (!partId)
565
611
  return;
566
612
  let next = part.text || "";
@@ -584,12 +630,19 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
584
630
  return;
585
631
  }
586
632
  if (part.type === "reasoning") {
587
- const partId = part.id || part.messageID;
588
633
  if (!partId)
589
634
  return;
635
+ // Deltas before type was known may have been buffered under the text key.
636
+ const orphanText = textByPartId.get(partId);
637
+ if (orphanText) {
638
+ textByPartId.delete(partId);
639
+ }
590
640
  let next = part.text || "";
591
641
  if (props.delta && !part.text) {
592
- next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
642
+ next = (textByPartId.get(`reasoning:${partId}`) || orphanText || "") + props.delta;
643
+ }
644
+ else if (!next && orphanText) {
645
+ next = orphanText;
593
646
  }
594
647
  if (part.text)
595
648
  next = part.text;
@@ -753,6 +806,38 @@ function isMissingOpencodeSessionError(message) {
753
806
  m.includes("unknown session") ||
754
807
  m.includes("invalid session"));
755
808
  }
809
+ /** Strip one layer of wrapping quotes models sometimes add when echoing. */
810
+ function stripOuterQuotes(text) {
811
+ const a = String(text || "").trim();
812
+ if ((a.startsWith('"') && a.endsWith('"')) ||
813
+ (a.startsWith("'") && a.endsWith("'"))) {
814
+ return a.slice(1, -1).trim();
815
+ }
816
+ return a;
817
+ }
818
+ /**
819
+ * True when assistant text is an echo of a prompt we just sent to OpenCode
820
+ * (raw user text and/or host-wrapped effectivePrompt) — exact or streaming prefix.
821
+ */
822
+ function isAssistantEchoOfSentPrompt(assistant, ...sentPrompts) {
823
+ const a = stripOuterQuotes(assistant);
824
+ if (!a)
825
+ return false;
826
+ for (const raw of sentPrompts) {
827
+ const p = String(raw || "").trim();
828
+ if (!p)
829
+ continue;
830
+ if (a === p)
831
+ return true;
832
+ // Streaming echo from the start of the wrapped prompt.
833
+ if (p.length >= 64 && a.length >= 24 && p.startsWith(a))
834
+ return true;
835
+ // Mid-wrap regurgitation (e.g. only the "Conversation so far:" section).
836
+ if (p.length >= 64 && a.length >= 40 && p.includes(a))
837
+ return true;
838
+ }
839
+ return false;
840
+ }
756
841
  function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
757
842
  const parts = [];
758
843
  if (workingBranch?.trim()) {
@@ -991,6 +1076,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
991
1076
  let buf = "";
992
1077
  let fatalError = null;
993
1078
  const textByPartId = new Map();
1079
+ const partTypeById = new Map();
994
1080
  let sawStdout = false;
995
1081
  let progressTicker = setInterval(() => {
996
1082
  if (sawStdout) {
@@ -1035,7 +1121,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1035
1121
  return;
1036
1122
  }
1037
1123
  // Newer OpenCode --format json uses bus shape (message.part.updated); normalize first.
1038
- const ev = normalizeStdoutOpencodeEvent(parsed) || parseOpencodeEvent(line);
1124
+ const ev = normalizeStdoutOpencodeEvent(parsed, partTypeById) || parseOpencodeEvent(line);
1039
1125
  if (!ev?.type)
1040
1126
  return;
1041
1127
  noteSessionId(ev.sessionID);
@@ -1450,19 +1536,13 @@ export async function runChimphands(opts) {
1450
1536
  }
1451
1537
  poster.fireAndForget(role, content, bodyOpts);
1452
1538
  };
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
- }
1539
+ /**
1540
+ * Drop ASSISTANT bubbles that echo the prompt we just sent — either the raw
1541
+ * user text or the host-wrapped effectivePrompt (exact string OpenCode got).
1542
+ */
1543
+ const postEventForTurn = (userPrompt, wrappedPrompt, role, content, opts) => {
1544
+ if (role === ROLE_ASSISTANT && isAssistantEchoOfSentPrompt(content, userPrompt, wrappedPrompt)) {
1545
+ return;
1466
1546
  }
1467
1547
  postEvent(role, content, opts);
1468
1548
  };
@@ -1564,7 +1644,7 @@ export async function runChimphands(opts) {
1564
1644
  let useOpencodeSessionId = opencodeSessionId;
1565
1645
  let isNewOpencodeSession = !useOpencodeSessionId;
1566
1646
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1567
- const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, role, content, opts);
1647
+ const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, effectivePrompt, role, content, opts);
1568
1648
  // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1569
1649
  // part completes — without this the UI looks empty while the turn is running.
1570
1650
  postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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",