@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.
- package/dist/chimphands/run.js +77 -35
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -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
|
-
|
|
144
|
-
//
|
|
145
|
-
if (
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1611
|
+
turnPostEvent(role, content, {
|
|
1570
1612
|
...opts,
|
|
1571
1613
|
opencodeSessionId,
|
|
1572
|
-
|
|
1614
|
+
durable: true,
|
|
1573
1615
|
});
|
|
1574
1616
|
}, noteWorkingBranch);
|
|
1575
1617
|
await poster.flush();
|
package/package.json
CHANGED