@testchimp/cli 0.1.57 → 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;
@@ -776,9 +829,12 @@ function isAssistantEchoOfSentPrompt(assistant, ...sentPrompts) {
776
829
  continue;
777
830
  if (a === p)
778
831
  return true;
779
- // Streaming echo of the (usually long) wrapped prompt: only when clearly a prefix.
832
+ // Streaming echo from the start of the wrapped prompt.
780
833
  if (p.length >= 64 && a.length >= 24 && p.startsWith(a))
781
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;
782
838
  }
783
839
  return false;
784
840
  }
@@ -1020,6 +1076,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1020
1076
  let buf = "";
1021
1077
  let fatalError = null;
1022
1078
  const textByPartId = new Map();
1079
+ const partTypeById = new Map();
1023
1080
  let sawStdout = false;
1024
1081
  let progressTicker = setInterval(() => {
1025
1082
  if (sawStdout) {
@@ -1064,7 +1121,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1064
1121
  return;
1065
1122
  }
1066
1123
  // Newer OpenCode --format json uses bus shape (message.part.updated); normalize first.
1067
- const ev = normalizeStdoutOpencodeEvent(parsed) || parseOpencodeEvent(line);
1124
+ const ev = normalizeStdoutOpencodeEvent(parsed, partTypeById) || parseOpencodeEvent(line);
1068
1125
  if (!ev?.type)
1069
1126
  return;
1070
1127
  noteSessionId(ev.sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.57",
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",