@testchimp/cli 0.1.57 → 0.1.59

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.
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { execSync, spawn } from "node:child_process";
7
7
  import { mkdirSync, openSync, writeFileSync } from "node:fs";
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
8
10
  import http from "node:http";
9
11
  import https from "node:https";
10
12
  import { URL } from "node:url";
@@ -280,6 +282,30 @@ function extractOpencodeFatalError(raw) {
280
282
  }
281
283
  return null;
282
284
  }
285
+ /**
286
+ * OpenCode `message.part.delta` always sends `field: "text"` for both reasoning and
287
+ * answer parts. Resolve kind from partID → type learned via `message.part.updated`.
288
+ */
289
+ function resolveDeltaPartKind(partId, field, partType, partTypeById) {
290
+ if (partType) {
291
+ partTypeById.set(partId, partType);
292
+ }
293
+ const known = partTypeById.get(partId);
294
+ if (known === "reasoning" || partType === "reasoning") {
295
+ partTypeById.set(partId, "reasoning");
296
+ return "reasoning";
297
+ }
298
+ if (known === "text" || partType === "text") {
299
+ partTypeById.set(partId, "text");
300
+ return "text";
301
+ }
302
+ // Unknown part: field is unreliable (reasoning deltas also use field "text").
303
+ if (field === "reasoning") {
304
+ partTypeById.set(partId, "reasoning");
305
+ return "reasoning";
306
+ }
307
+ return "text";
308
+ }
283
309
  function parseOpencodeEvent(line) {
284
310
  try {
285
311
  return JSON.parse(line);
@@ -292,8 +318,11 @@ function parseOpencodeEvent(line) {
292
318
  * Newer OpenCode `--format json` lines often use the SSE bus shape
293
319
  * (`message.part.updated` + `properties.part`) instead of legacy `type: "text"`.
294
320
  * Normalize both into the same OpencodeEvent used by the stdout switch.
321
+ *
322
+ * `partTypeById` must be shared across lines: deltas use `field: "text"` for
323
+ * reasoning parts too — type comes from earlier `message.part.updated`.
295
324
  */
296
- function normalizeStdoutOpencodeEvent(raw) {
325
+ function normalizeStdoutOpencodeEvent(raw, partTypeById) {
297
326
  if (!raw || typeof raw !== "object")
298
327
  return null;
299
328
  const o = raw;
@@ -312,18 +341,25 @@ function normalizeStdoutOpencodeEvent(raw) {
312
341
  const deltaProps = props;
313
342
  const part = deltaProps.part;
314
343
  if (part) {
315
- const partType = part.type || deltaProps.field || "";
344
+ const partId = part.id || part.messageID || deltaProps.partID || "";
345
+ const kind = partId
346
+ ? resolveDeltaPartKind(partId, deltaProps.field, part.type, partTypeById)
347
+ : part.type === "reasoning"
348
+ ? "reasoning"
349
+ : "text";
316
350
  let mapped;
317
- if (partType === "text")
351
+ if (kind === "text" || part.type === "text")
318
352
  mapped = "text";
319
- else if (partType === "reasoning")
353
+ else if (kind === "reasoning" || part.type === "reasoning")
320
354
  mapped = "reasoning";
321
- else if (partType === "tool")
355
+ else if (part.type === "tool")
322
356
  mapped = "tool_use";
323
357
  else
324
358
  return null;
325
359
  if (deltaProps.delta && !part.text)
326
360
  part.text = deltaProps.delta;
361
+ if (mapped === "reasoning")
362
+ part.type = "reasoning";
327
363
  return {
328
364
  type: mapped,
329
365
  sessionID: part.sessionID || deltaProps.sessionID,
@@ -331,22 +367,29 @@ function normalizeStdoutOpencodeEvent(raw) {
331
367
  };
332
368
  }
333
369
  const partID = deltaProps.partID;
334
- const field = deltaProps.field || "text";
335
370
  const delta = deltaProps.delta;
336
371
  if (!partID || delta == null || delta === "")
337
372
  return null;
338
- if (field !== "text" && field !== "reasoning")
373
+ if (deltaProps.field && deltaProps.field !== "text" && deltaProps.field !== "reasoning") {
374
+ return null;
375
+ }
376
+ const kind = resolveDeltaPartKind(partID, deltaProps.field, undefined, partTypeById);
377
+ if (kind !== "text" && kind !== "reasoning")
339
378
  return null;
340
379
  return {
341
- type: field === "reasoning" ? "reasoning" : "text",
380
+ type: kind,
342
381
  sessionID: deltaProps.sessionID,
343
- part: { id: partID, type: field, text: delta },
382
+ part: { id: partID, type: kind, text: delta },
344
383
  };
345
384
  }
346
385
  if (type === "message.part.updated") {
347
386
  const part = props.part;
348
387
  if (!part)
349
388
  return null;
389
+ const partId = part.id || part.messageID;
390
+ if (partId && part.type) {
391
+ partTypeById.set(partId, part.type);
392
+ }
350
393
  const partType = part.type || "";
351
394
  let mapped;
352
395
  if (partType === "text")
@@ -487,6 +530,8 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
487
530
  const ac = new AbortController();
488
531
  let stopped = false;
489
532
  const textByPartId = new Map();
533
+ /** partID → "text" | "reasoning" | … from message.part.updated (deltas lie about field). */
534
+ const partTypeById = new Map();
490
535
  const directory = process.cwd();
491
536
  const sessionMatches = (sessionId) => {
492
537
  const active = callbacks.getActiveSessionId();
@@ -519,18 +564,27 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
519
564
  const ev = unwrapped;
520
565
  const type = ev.type || "";
521
566
  const props = ev.properties || {};
522
- // Token stream: { partID, field, delta } — often no `part` object.
567
+ // Token stream: { partID, field, delta } — field is usually "text" even for reasoning.
523
568
  if (type === "message.part.delta") {
524
569
  const part = props.part;
525
570
  const partId = props.partID || part?.id || part?.messageID;
526
- const field = props.field || part?.type || "text";
527
571
  const sessionId = part?.sessionID || props.sessionID;
528
572
  if (!sessionMatches(sessionId))
529
573
  return;
530
574
  callbacks.noteSessionId(sessionId);
531
575
  if (!partId || props.delta == null || props.delta === "")
532
576
  return;
533
- if (field === "text") {
577
+ const kind = resolveDeltaPartKind(partId, props.field, part?.type, partTypeById);
578
+ if (kind === "reasoning") {
579
+ const key = `reasoning:${partId}`;
580
+ const next = (textByPartId.get(key) || "") + props.delta;
581
+ textByPartId.set(key, next);
582
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
583
+ messageId: `oc_reasoning_${partId}`,
584
+ }));
585
+ return;
586
+ }
587
+ if (kind === "text") {
534
588
  const next = (textByPartId.get(partId) || "") + props.delta;
535
589
  textByPartId.set(partId, next);
536
590
  if (isTextDuplicateOfReasoning(next, reasoningBodiesFromPartMap(textByPartId))) {
@@ -539,15 +593,6 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
539
593
  callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
540
594
  messageId: `oc_text_${partId}`,
541
595
  }));
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
596
  }
552
597
  return;
553
598
  }
@@ -559,8 +604,11 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
559
604
  if (!sessionMatches(sessionId))
560
605
  return;
561
606
  callbacks.noteSessionId(sessionId);
607
+ const partId = part.id || part.messageID;
608
+ if (partId && part.type) {
609
+ partTypeById.set(partId, part.type);
610
+ }
562
611
  if (part.type === "text") {
563
- const partId = part.id || part.messageID;
564
612
  if (!partId)
565
613
  return;
566
614
  let next = part.text || "";
@@ -584,12 +632,19 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
584
632
  return;
585
633
  }
586
634
  if (part.type === "reasoning") {
587
- const partId = part.id || part.messageID;
588
635
  if (!partId)
589
636
  return;
637
+ // Deltas before type was known may have been buffered under the text key.
638
+ const orphanText = textByPartId.get(partId);
639
+ if (orphanText) {
640
+ textByPartId.delete(partId);
641
+ }
590
642
  let next = part.text || "";
591
643
  if (props.delta && !part.text) {
592
- next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
644
+ next = (textByPartId.get(`reasoning:${partId}`) || orphanText || "") + props.delta;
645
+ }
646
+ else if (!next && orphanText) {
647
+ next = orphanText;
593
648
  }
594
649
  if (part.text)
595
650
  next = part.text;
@@ -776,9 +831,12 @@ function isAssistantEchoOfSentPrompt(assistant, ...sentPrompts) {
776
831
  continue;
777
832
  if (a === p)
778
833
  return true;
779
- // Streaming echo of the (usually long) wrapped prompt: only when clearly a prefix.
834
+ // Streaming echo from the start of the wrapped prompt.
780
835
  if (p.length >= 64 && a.length >= 24 && p.startsWith(a))
781
836
  return true;
837
+ // Mid-wrap regurgitation (e.g. only the "Conversation so far:" section).
838
+ if (p.length >= 64 && a.length >= 40 && p.includes(a))
839
+ return true;
782
840
  }
783
841
  return false;
784
842
  }
@@ -1003,6 +1061,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1003
1061
  stdio: ["pipe", "pipe", "pipe"],
1004
1062
  env: childEnv,
1005
1063
  });
1064
+ callbacks.onActiveChild?.(child);
1006
1065
  try {
1007
1066
  child.stdin?.end();
1008
1067
  }
@@ -1020,6 +1079,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1020
1079
  let buf = "";
1021
1080
  let fatalError = null;
1022
1081
  const textByPartId = new Map();
1082
+ const partTypeById = new Map();
1023
1083
  let sawStdout = false;
1024
1084
  let progressTicker = setInterval(() => {
1025
1085
  if (sawStdout) {
@@ -1064,7 +1124,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1064
1124
  return;
1065
1125
  }
1066
1126
  // Newer OpenCode --format json uses bus shape (message.part.updated); normalize first.
1067
- const ev = normalizeStdoutOpencodeEvent(parsed) || parseOpencodeEvent(line);
1127
+ const ev = normalizeStdoutOpencodeEvent(parsed, partTypeById) || parseOpencodeEvent(line);
1068
1128
  if (!ev?.type)
1069
1129
  return;
1070
1130
  noteSessionId(ev.sessionID);
@@ -1175,6 +1235,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1175
1235
  }
1176
1236
  });
1177
1237
  child.on("close", (code) => {
1238
+ callbacks.onActiveChild?.(null);
1178
1239
  if (progressTicker) {
1179
1240
  clearInterval(progressTicker);
1180
1241
  progressTicker = null;
@@ -1185,6 +1246,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1185
1246
  const stderrFatal = extractOpencodeFatalError(err);
1186
1247
  if (stderrFatal)
1187
1248
  fatalError = stderrFatal;
1249
+ if (callbacks.getCancelRequested?.()) {
1250
+ resolve({ code: 0, err: "", opencodeSessionId: activeSessionId, cancelled: true });
1251
+ return;
1252
+ }
1188
1253
  if (fatalError) {
1189
1254
  resolve({ code: 1, err: fatalError, opencodeSessionId: activeSessionId });
1190
1255
  return;
@@ -1201,6 +1266,63 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1201
1266
  });
1202
1267
  });
1203
1268
  }
1269
+ function normalizeWorktreeRelativePath(filePath) {
1270
+ let p = String(filePath || "").trim().replace(/\\/g, "/");
1271
+ while (p.startsWith("/"))
1272
+ p = p.slice(1);
1273
+ if (!p || p.includes("\0")) {
1274
+ throw new Error("invalid path");
1275
+ }
1276
+ const segments = [];
1277
+ for (const part of p.split("/")) {
1278
+ if (!part || part === ".")
1279
+ continue;
1280
+ if (part === "..") {
1281
+ if (!segments.length)
1282
+ throw new Error("invalid path");
1283
+ segments.pop();
1284
+ continue;
1285
+ }
1286
+ segments.push(part);
1287
+ }
1288
+ if (!segments.length)
1289
+ throw new Error("invalid path");
1290
+ return segments.join("/");
1291
+ }
1292
+ async function ackWorktreeFileWrite(backend, apiKey, sessionId, requestId, ok, errorMessage) {
1293
+ const body = {
1294
+ sessionId,
1295
+ requestId,
1296
+ ok,
1297
+ };
1298
+ if (errorMessage)
1299
+ body.errorMessage = errorMessage.slice(0, 2000);
1300
+ await postJson(backend, apiKey, "/api/chimphands/ack_worktree_file_write", body).catch((err) => {
1301
+ console.error(`ChimpHands ack_worktree_file_write failed: ${err instanceof Error ? err.message : String(err)}`);
1302
+ });
1303
+ }
1304
+ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1305
+ if (turnActive()) {
1306
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, "agent turn in progress");
1307
+ return;
1308
+ }
1309
+ try {
1310
+ const relative = normalizeWorktreeRelativePath(edit.path);
1311
+ const root = process.cwd();
1312
+ const full = path.resolve(root, relative);
1313
+ const rootResolved = path.resolve(root);
1314
+ if (full !== rootResolved && !full.startsWith(rootResolved + path.sep)) {
1315
+ throw new Error("path outside worktree");
1316
+ }
1317
+ await fs.mkdir(path.dirname(full), { recursive: true });
1318
+ await fs.writeFile(full, edit.content, "utf8");
1319
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1320
+ }
1321
+ catch (err) {
1322
+ const msg = err instanceof Error ? err.message : String(err);
1323
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, msg);
1324
+ }
1325
+ }
1204
1326
  function connectInboundStream(backend, apiKey, sessionId, handlers) {
1205
1327
  const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
1206
1328
  const lib = url.protocol === "https:" ? https : http;
@@ -1248,6 +1370,37 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1248
1370
  if (eventName === "idle") {
1249
1371
  handlers.onIdle();
1250
1372
  }
1373
+ else if (eventName === "cancel_turn") {
1374
+ try {
1375
+ const payload = JSON.parse(data);
1376
+ if (payload.sessionId && payload.sessionId !== sessionId) {
1377
+ continue;
1378
+ }
1379
+ }
1380
+ catch {
1381
+ /* ignore malformed payload */
1382
+ }
1383
+ handlers.onCancelTurn?.();
1384
+ }
1385
+ else if (eventName === "user_file_edit") {
1386
+ try {
1387
+ const edit = JSON.parse(data);
1388
+ if (edit.sessionId && edit.sessionId !== sessionId) {
1389
+ continue;
1390
+ }
1391
+ const requestId = edit.requestId || edit.request_id;
1392
+ if (requestId && edit.path) {
1393
+ handlers.onUserFileEdit?.({
1394
+ requestId,
1395
+ path: edit.path,
1396
+ content: edit.content ?? "",
1397
+ });
1398
+ }
1399
+ }
1400
+ catch {
1401
+ /* ignore */
1402
+ }
1403
+ }
1251
1404
  else if (eventName === "user_message" || eventName === "message") {
1252
1405
  try {
1253
1406
  const msg = JSON.parse(data);
@@ -1441,6 +1594,15 @@ export async function runChimphands(opts) {
1441
1594
  let sessionActive = true;
1442
1595
  let lastUserActivity = Date.now();
1443
1596
  let exitCode;
1597
+ let cancelTurnRequested = false;
1598
+ let activeOpencodeChild = null;
1599
+ let agentTurnInProgress = false;
1600
+ const turnControl = {
1601
+ getCancelRequested: () => cancelTurnRequested,
1602
+ onActiveChild: (child) => {
1603
+ activeOpencodeChild = child;
1604
+ },
1605
+ };
1444
1606
  const enqueueUserMessage = (msg) => {
1445
1607
  const id = msg.id?.trim();
1446
1608
  if (id) {
@@ -1512,6 +1674,21 @@ export async function runChimphands(opts) {
1512
1674
  onIdle: () => {
1513
1675
  idle = true;
1514
1676
  },
1677
+ onCancelTurn: () => {
1678
+ cancelTurnRequested = true;
1679
+ const ch = activeOpencodeChild;
1680
+ if (ch && !ch.killed) {
1681
+ try {
1682
+ ch.kill("SIGTERM");
1683
+ }
1684
+ catch {
1685
+ /* ignore */
1686
+ }
1687
+ }
1688
+ },
1689
+ onUserFileEdit: (edit) => {
1690
+ void applyUserFileEdit(backend, apiKey, sessionId, edit, () => agentTurnInProgress);
1691
+ },
1515
1692
  shouldRun: () => sessionActive,
1516
1693
  });
1517
1694
  const shutdownRuntime = async () => {
@@ -1584,6 +1761,8 @@ export async function runChimphands(opts) {
1584
1761
  tick();
1585
1762
  });
1586
1763
  while (prompt) {
1764
+ cancelTurnRequested = false;
1765
+ agentTurnInProgress = true;
1587
1766
  let useOpencodeSessionId = opencodeSessionId;
1588
1767
  let isNewOpencodeSession = !useOpencodeSessionId;
1589
1768
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
@@ -1601,6 +1780,7 @@ export async function runChimphands(opts) {
1601
1780
  },
1602
1781
  onWorkingBranch: noteWorkingBranch,
1603
1782
  postEvent: turnPostEvent,
1783
+ ...turnControl,
1604
1784
  }, attachUrl);
1605
1785
  if (result.code !== 0 &&
1606
1786
  useOpencodeSessionId &&
@@ -1619,8 +1799,10 @@ export async function runChimphands(opts) {
1619
1799
  },
1620
1800
  onWorkingBranch: noteWorkingBranch,
1621
1801
  postEvent: turnPostEvent,
1802
+ ...turnControl,
1622
1803
  }, attachUrl);
1623
1804
  }
1805
+ agentTurnInProgress = false;
1624
1806
  await poster.flush();
1625
1807
  if (result.opencodeSessionId) {
1626
1808
  opencodeSessionId = result.opencodeSessionId;
@@ -1643,7 +1825,7 @@ export async function runChimphands(opts) {
1643
1825
  console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1644
1826
  }
1645
1827
  }
1646
- if (result.code !== 0) {
1828
+ if (result.code !== 0 && !result.cancelled) {
1647
1829
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
1648
1830
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
1649
1831
  try {
@@ -1667,7 +1849,12 @@ export async function runChimphands(opts) {
1667
1849
  exitCode = result.code || 1;
1668
1850
  break;
1669
1851
  }
1670
- postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1852
+ if (result.cancelled) {
1853
+ postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
1854
+ }
1855
+ else {
1856
+ postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1857
+ }
1671
1858
  lastUserActivity = Date.now();
1672
1859
  idle = false;
1673
1860
  prompt = (await waitForNextPrompt()) || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
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",