@testchimp/cli 0.1.51 → 0.1.52

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.
Files changed (2) hide show
  1. package/dist/chimphands/run.js +166 -105
  2. package/package.json +1 -1
@@ -137,9 +137,12 @@ class AgentEventPoster {
137
137
  if (opts?.pullRequestUrl)
138
138
  body.pullRequestUrl = opts.pullRequestUrl;
139
139
  const streamRole = isStreamFanoutRole(role);
140
- // Never drop liveStream tokens: ephemeral when UI is on this replica path,
141
- // otherwise durable post_agent_event (cross-replica / detached safe).
142
140
  this.chain = this.chain.then(async () => {
141
+ // Mid-turn live tokens only while UI is attached — async runs skip FS fanout
142
+ // and rely on turn-end reconcile for durable PG.
143
+ if (opts?.liveStream && !this.uiAttached) {
144
+ return;
145
+ }
143
146
  if (opts?.throttle || (streamRole && opts?.liveStream)) {
144
147
  const now = Date.now();
145
148
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
@@ -170,11 +173,21 @@ class AgentEventPoster {
170
173
  }
171
174
  if (delivered)
172
175
  return;
173
- // Cross-replica: UI SSE not on this FS podfall through to durable.
176
+ // Mid-turn liveStream must not fall through to durablethat would write
177
+ // every token to PG. Turn-end reconcile persists the final transcript.
178
+ if (opts?.liveStream) {
179
+ console.error("ChimpHands ephemeral not delivered (replica miss?) — skipping durable for liveStream");
180
+ return;
181
+ }
182
+ // Completed/non-live: durable fallback for cross-replica UI.
174
183
  console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
175
184
  }
176
185
  catch (err) {
177
186
  const detail = err instanceof Error ? err.message : String(err);
187
+ if (opts?.liveStream) {
188
+ console.error(`ChimpHands ephemeral post failed — skipping durable for liveStream: ${detail}`);
189
+ return;
190
+ }
178
191
  console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
179
192
  }
180
193
  }
@@ -274,7 +287,43 @@ function normalizeStdoutOpencodeEvent(raw) {
274
287
  const props = (inner.properties && typeof inner.properties === "object"
275
288
  ? inner.properties
276
289
  : {});
277
- if (type === "message.part.updated" || type === "message.part.delta") {
290
+ if (type === "message.part.delta") {
291
+ // OpenCode streams tokens as { partID, field, delta } with no `part`.
292
+ const deltaProps = props;
293
+ const part = deltaProps.part;
294
+ if (part) {
295
+ const partType = part.type || deltaProps.field || "";
296
+ let mapped;
297
+ if (partType === "text")
298
+ mapped = "text";
299
+ else if (partType === "reasoning")
300
+ mapped = "reasoning";
301
+ else if (partType === "tool")
302
+ mapped = "tool_use";
303
+ else
304
+ return null;
305
+ if (deltaProps.delta && !part.text)
306
+ part.text = deltaProps.delta;
307
+ return {
308
+ type: mapped,
309
+ sessionID: part.sessionID || deltaProps.sessionID,
310
+ part,
311
+ };
312
+ }
313
+ const partID = deltaProps.partID;
314
+ const field = deltaProps.field || "text";
315
+ const delta = deltaProps.delta;
316
+ if (!partID || delta == null || delta === "")
317
+ return null;
318
+ if (field !== "text" && field !== "reasoning")
319
+ return null;
320
+ return {
321
+ type: field === "reasoning" ? "reasoning" : "text",
322
+ sessionID: deltaProps.sessionID,
323
+ part: { id: partID, type: field, text: delta },
324
+ };
325
+ }
326
+ if (type === "message.part.updated") {
278
327
  const part = props.part;
279
328
  if (!part)
280
329
  return null;
@@ -288,7 +337,6 @@ function normalizeStdoutOpencodeEvent(raw) {
288
337
  mapped = "tool_use";
289
338
  else
290
339
  return null;
291
- // Prefer cumulative text; append delta when that's all we got.
292
340
  if (props.delta && !part.text) {
293
341
  part.text = props.delta;
294
342
  }
@@ -311,7 +359,7 @@ function normalizeStdoutOpencodeEvent(raw) {
311
359
  }
312
360
  return null;
313
361
  }
314
- /** Pull assistant/tool parts from OpenCode HTTP after attach exits early. */
362
+ /** Pull assistant/tool parts from OpenCode HTTP for durable PG (turn-end). */
315
363
  async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
316
364
  const base = attachUrl.replace(/\/$/, "");
317
365
  const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
@@ -332,8 +380,18 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
332
380
  const data = (await res.json());
333
381
  if (!Array.isArray(data))
334
382
  return 0;
383
+ // Only parts after the latest user message (this turn). Full-history reconcile
384
+ // every turn would O(n) upsert the entire transcript for no benefit.
385
+ let lastUserIdx = -1;
386
+ for (let i = data.length - 1; i >= 0; i--) {
387
+ if ((data[i]?.info?.role || "").toLowerCase() === "user") {
388
+ lastUserIdx = i;
389
+ break;
390
+ }
391
+ }
392
+ const turnSlice = lastUserIdx >= 0 ? data.slice(lastUserIdx + 1) : data;
335
393
  let posted = 0;
336
- for (const msg of data) {
394
+ for (const msg of turnSlice) {
337
395
  if ((msg.info?.role || "").toLowerCase() !== "assistant")
338
396
  continue;
339
397
  for (const part of msg.parts || []) {
@@ -367,7 +425,7 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
367
425
  }
368
426
  }
369
427
  if (posted) {
370
- console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
428
+ console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API (this turn)`);
371
429
  }
372
430
  return posted;
373
431
  }
@@ -434,7 +492,36 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
434
492
  const ev = unwrapped;
435
493
  const type = ev.type || "";
436
494
  const props = ev.properties || {};
437
- if (type === "message.part.updated" || type === "message.part.delta") {
495
+ // Token stream: { partID, field, delta } — often no `part` object.
496
+ if (type === "message.part.delta") {
497
+ const part = props.part;
498
+ const partId = props.partID || part?.id || part?.messageID;
499
+ const field = props.field || part?.type || "text";
500
+ const sessionId = part?.sessionID || props.sessionID;
501
+ if (!sessionMatches(sessionId))
502
+ return;
503
+ callbacks.noteSessionId(sessionId);
504
+ if (!partId || props.delta == null || props.delta === "")
505
+ return;
506
+ if (field === "text") {
507
+ const next = (textByPartId.get(partId) || "") + props.delta;
508
+ textByPartId.set(partId, next);
509
+ callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
510
+ messageId: `oc_text_${partId}`,
511
+ }));
512
+ return;
513
+ }
514
+ if (field === "reasoning") {
515
+ const key = `reasoning:${partId}`;
516
+ const next = (textByPartId.get(key) || "") + props.delta;
517
+ textByPartId.set(key, next);
518
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
519
+ messageId: `oc_reasoning_${partId}`,
520
+ }));
521
+ }
522
+ return;
523
+ }
524
+ if (type === "message.part.updated") {
438
525
  const part = props.part;
439
526
  if (!part)
440
527
  return;
@@ -447,13 +534,12 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
447
534
  if (!partId)
448
535
  return;
449
536
  let next = part.text || "";
450
- if (props.delta) {
537
+ if (props.delta && !part.text) {
451
538
  next = (textByPartId.get(partId) || "") + props.delta;
452
539
  }
453
540
  else if (!next && props.delta === undefined) {
454
541
  return;
455
542
  }
456
- // Prefer cumulative part.text when present (idempotent); else delta accumulation.
457
543
  if (part.text)
458
544
  next = part.text;
459
545
  textByPartId.set(partId, next);
@@ -484,8 +570,17 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
484
570
  }
485
571
  if (part.type === "tool") {
486
572
  const status = part.state?.status;
487
- if (!status || status === "pending" || status === "running")
573
+ if (!status || status === "pending")
574
+ return;
575
+ // Ephemeral "(running)" bubbles while UI attached (liveStream gated in poster).
576
+ if (status === "running") {
577
+ const toolContent = formatToolUseContent(part);
578
+ callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
579
+ messageId: opencodeMessageId("oc_tool_", part),
580
+ throttle: false,
581
+ }));
488
582
  return;
583
+ }
489
584
  const toolContent = formatToolUseContent(part);
490
585
  callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
491
586
  messageId: opencodeMessageId("oc_tool_", part),
@@ -897,6 +992,19 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
897
992
  if (!ev?.type)
898
993
  return;
899
994
  noteSessionId(ev.sessionID);
995
+ switch (ev.type) {
996
+ case "text":
997
+ case "reasoning":
998
+ case "tool_use": {
999
+ // Attach mode: live tokens come from OpenCode SSE; durable from turn-end
1000
+ // reconcile. Avoid double-fanout / double-accumulation with stdout.
1001
+ if (attachUrl)
1002
+ return;
1003
+ break;
1004
+ }
1005
+ default:
1006
+ break;
1007
+ }
900
1008
  switch (ev.type) {
901
1009
  case "text": {
902
1010
  const chunk = ev.part?.text;
@@ -907,7 +1015,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
907
1015
  callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
908
1016
  return;
909
1017
  }
910
- const next = (textByPartId.get(partId) || "") + chunk;
1018
+ // Without attach, --format json may emit completed cumulative or deltas.
1019
+ // Prefer replace when we already have longer text (cumulative); else append.
1020
+ const prev = textByPartId.get(partId) || "";
1021
+ const next = !prev
1022
+ ? chunk
1023
+ : chunk.startsWith(prev)
1024
+ ? chunk
1025
+ : prev + chunk;
911
1026
  textByPartId.set(partId, next);
912
1027
  callbacks.postEvent(ROLE_ASSISTANT, next, {
913
1028
  throttle: true,
@@ -925,7 +1040,12 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
925
1040
  return;
926
1041
  }
927
1042
  const reasoningKey = `reasoning:${partId}`;
928
- const next = (textByPartId.get(reasoningKey) || "") + chunk;
1043
+ const prev = textByPartId.get(reasoningKey) || "";
1044
+ const next = !prev
1045
+ ? chunk
1046
+ : chunk.startsWith(prev)
1047
+ ? chunk
1048
+ : prev + chunk;
929
1049
  textByPartId.set(reasoningKey, next);
930
1050
  callbacks.postEvent(ROLE_REASONING, next, {
931
1051
  throttle: true,
@@ -934,8 +1054,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
934
1054
  return;
935
1055
  }
936
1056
  case "tool_use": {
1057
+ // Durable-only path (no attach). Skip in-progress.
937
1058
  const status = ev.part?.state?.status;
938
- if (!status || status === "pending")
1059
+ if (!status || status === "pending" || status === "running")
939
1060
  return;
940
1061
  const toolContent = formatToolUseContent(ev.part);
941
1062
  callbacks.postEvent(ROLE_TOOL, toolContent, {
@@ -1122,11 +1243,14 @@ export async function runChimphands(opts) {
1122
1243
  });
1123
1244
  };
1124
1245
  const syncLiveSse = (attached) => {
1125
- poster.uiAttached = attached;
1126
1246
  pendingUiAttached = attached;
1127
- if (!attachUrl)
1247
+ if (!attachUrl) {
1248
+ poster.uiAttached = attached;
1128
1249
  return;
1250
+ }
1129
1251
  if (!opencodeHttpReady) {
1252
+ // Defer SSE; still track intent so first sync after ready is correct.
1253
+ poster.uiAttached = attached;
1130
1254
  if (attached) {
1131
1255
  console.error("ChimpHands UI attached — deferring OpenCode SSE until serve is ready");
1132
1256
  }
@@ -1134,18 +1258,18 @@ export async function runChimphands(opts) {
1134
1258
  }
1135
1259
  if (attached) {
1136
1260
  consecutiveUiDetached = 0;
1261
+ // Allow liveStream immediately when heartbeat says attached.
1262
+ poster.uiAttached = true;
1137
1263
  startLiveSseIfNeeded("UI attached — starting OpenCode SSE fanout");
1138
1264
  return;
1139
1265
  }
1140
- // Keep SSE running so live tokens can durable-post even when heartbeat
1141
- // briefly reports false (UI EventSource on another FS replica).
1266
+ // Detached / async: hysteresis avoids flap from cross-replica heartbeats.
1267
+ // Keep poster.uiAttached true + SSE up during the window so we don't drop
1268
+ // live tokens while the UI is still actually listening on another replica.
1142
1269
  consecutiveUiDetached += 1;
1143
- if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER) {
1144
- if (!stopLiveSse) {
1145
- startLiveSseIfNeeded("starting OpenCode SSE fanout (durable until UI attaches)");
1146
- }
1270
+ if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER)
1147
1271
  return;
1148
- }
1272
+ poster.uiAttached = false;
1149
1273
  if (stopLiveSse) {
1150
1274
  console.error(`ChimpHands UI detached for ${consecutiveUiDetached} heartbeats — stopping OpenCode SSE fanout`);
1151
1275
  stopLiveSse();
@@ -1211,30 +1335,6 @@ export async function runChimphands(opts) {
1211
1335
  const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
1212
1336
  let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
1213
1337
  let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
1214
- const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
1215
- if (exportSignedUrl && attachUrl) {
1216
- try {
1217
- const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
1218
- if (importedId) {
1219
- opencodeSessionId = importedId;
1220
- console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
1221
- }
1222
- }
1223
- catch (err) {
1224
- console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1225
- }
1226
- }
1227
- const snapshotExport = async () => {
1228
- const sid = opencodeSessionId?.trim();
1229
- if (!sid)
1230
- return;
1231
- try {
1232
- await putOpencodeExport(backend, apiKey, sessionId, sid);
1233
- }
1234
- catch (err) {
1235
- console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1236
- }
1237
- };
1238
1338
  noteWorkingBranch = (branch, prUrl) => {
1239
1339
  const normalizedBranch = branch.trim();
1240
1340
  if (!normalizedBranch)
@@ -1334,7 +1434,6 @@ export async function runChimphands(opts) {
1334
1434
  }
1335
1435
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
1336
1436
  await poster.flush();
1337
- await snapshotExport();
1338
1437
  if (runtimeId) {
1339
1438
  await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
1340
1439
  runtimeId,
@@ -1434,6 +1533,24 @@ export async function runChimphands(opts) {
1434
1533
  if (result.opencodeSessionId) {
1435
1534
  opencodeSessionId = result.opencodeSessionId;
1436
1535
  }
1536
+ // Turn-end durable reconcile into PG (idempotent messageIds). Mid-turn was
1537
+ // ephemeral-only when UI attached; async runs get their transcript here.
1538
+ // Also reconcile on failure so partial assistant/tool output is not lost.
1539
+ if (attachUrl && opencodeSessionId) {
1540
+ try {
1541
+ await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
1542
+ void poster.enqueue(role, content, {
1543
+ ...opts,
1544
+ opencodeSessionId,
1545
+ // Explicitly not liveStream → always durable post_agent_event.
1546
+ });
1547
+ }, noteWorkingBranch);
1548
+ await poster.flush();
1549
+ }
1550
+ catch (err) {
1551
+ console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1552
+ }
1553
+ }
1437
1554
  if (result.code !== 0) {
1438
1555
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
1439
1556
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -1459,7 +1576,6 @@ export async function runChimphands(opts) {
1459
1576
  break;
1460
1577
  }
1461
1578
  postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1462
- await snapshotExport();
1463
1579
  lastUserActivity = Date.now();
1464
1580
  idle = false;
1465
1581
  prompt = (await waitForNextPrompt()) || "";
@@ -1575,61 +1691,6 @@ async function commitAndPushDirtyWorktree(message) {
1575
1691
  console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
1576
1692
  }
1577
1693
  }
1578
- async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
1579
- const exported = await new Promise((resolve, reject) => {
1580
- const child = spawn("opencode", ["export", opencodeSessionId], {
1581
- stdio: ["ignore", "pipe", "pipe"],
1582
- });
1583
- let out = "";
1584
- let err = "";
1585
- child.stdout.on("data", (d) => {
1586
- out += d.toString();
1587
- });
1588
- child.stderr.on("data", (d) => {
1589
- err += d.toString();
1590
- });
1591
- child.on("close", (code) => {
1592
- if (code === 0 && out.trim())
1593
- resolve(out);
1594
- else
1595
- reject(new Error(err.trim() || `opencode export exited ${code}`));
1596
- });
1597
- });
1598
- const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
1599
- await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
1600
- sessionId,
1601
- exportBase64,
1602
- });
1603
- }
1604
- async function importOpencodeExportFromUrl(signedUrl) {
1605
- const res = await fetch(signedUrl);
1606
- if (!res.ok) {
1607
- throw new Error(`download export failed: ${res.status}`);
1608
- }
1609
- const text = await res.text();
1610
- writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
1611
- return await new Promise((resolve, reject) => {
1612
- const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
1613
- stdio: ["ignore", "pipe", "pipe"],
1614
- });
1615
- let out = "";
1616
- let err = "";
1617
- child.stdout.on("data", (d) => {
1618
- out += d.toString();
1619
- });
1620
- child.stderr.on("data", (d) => {
1621
- err += d.toString();
1622
- });
1623
- child.on("close", (code) => {
1624
- if (code !== 0) {
1625
- reject(new Error(err.trim() || `opencode import exited ${code}`));
1626
- return;
1627
- }
1628
- const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
1629
- resolve(match?.[0]);
1630
- });
1631
- });
1632
- }
1633
1694
  function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1634
1695
  let stopped = false;
1635
1696
  const base = attachUrl.replace(/\/$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
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",