@testchimp/cli 0.1.37 → 0.1.39

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.
@@ -16,12 +16,8 @@ const STATUS_RUNNING = "CHIMPHANDS_SESSION_STATUS_RUNNING";
16
16
  const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
17
17
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
18
18
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
19
- function ensureTestchimpPrompt(content) {
20
- const trimmed = content.trim();
21
- if (!trimmed)
22
- return trimmed;
23
- const rest = trimmed.replace(/^\/testchimp\s*/i, "").trim();
24
- return rest ? `/testchimp ${rest}` : "/testchimp";
19
+ function normalizeUserMessage(content) {
20
+ return content.trim();
25
21
  }
26
22
  function apiHeaders(apiKey) {
27
23
  return {
@@ -63,7 +59,11 @@ function extractOpencodeFatalError(raw) {
63
59
  try {
64
60
  const ev = JSON.parse(line);
65
61
  if (ev.type === "error" || ev.name === "UnknownError") {
66
- const msg = ev.data?.message || ev.message || line;
62
+ const msg = ev.error?.data?.message ||
63
+ ev.error?.message ||
64
+ ev.data?.message ||
65
+ ev.message ||
66
+ line;
67
67
  const ref = ev.data?.ref ? ` (ref ${ev.data.ref})` : "";
68
68
  return `${msg}${ref}`;
69
69
  }
@@ -83,6 +83,27 @@ function extractOpencodeFatalError(raw) {
83
83
  }
84
84
  return null;
85
85
  }
86
+ function parseOpencodeEvent(line) {
87
+ try {
88
+ return JSON.parse(line);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function formatToolUseContent(part) {
95
+ const title = part.state?.title || part.tool || "tool";
96
+ const output = part.state?.output?.trim();
97
+ if (output)
98
+ return `[${title}]\n${output}`;
99
+ return `[${title}]`;
100
+ }
101
+ function opencodeMessageId(prefix, part) {
102
+ const raw = part?.id || part?.messageID;
103
+ if (!raw)
104
+ return undefined;
105
+ return `${prefix}${raw}`;
106
+ }
86
107
  function summarizeOpencodeFailure(stderr, stdout, exitCode) {
87
108
  for (const chunk of [stderr, stdout]) {
88
109
  for (const line of chunk.split("\n")) {
@@ -169,41 +190,65 @@ function runOpencode(prompt, model, childEnv, postEvent) {
169
190
  return new Promise((resolve) => {
170
191
  let buf = "";
171
192
  let fatalError = null;
193
+ const textByPartId = new Map();
194
+ const handleOpencodeLine = (line) => {
195
+ if (!line.trim())
196
+ return;
197
+ const fatal = extractOpencodeFatalError(line);
198
+ if (fatal) {
199
+ fatalError = fatal;
200
+ return;
201
+ }
202
+ const ev = parseOpencodeEvent(line);
203
+ if (!ev?.type)
204
+ return;
205
+ switch (ev.type) {
206
+ case "text": {
207
+ const chunk = ev.part?.text;
208
+ if (!chunk)
209
+ return;
210
+ const partId = ev.part?.id || ev.part?.messageID;
211
+ if (!partId) {
212
+ postEvent(ROLE_ASSISTANT, chunk);
213
+ return;
214
+ }
215
+ const next = (textByPartId.get(partId) || "") + chunk;
216
+ textByPartId.set(partId, next);
217
+ postEvent(ROLE_ASSISTANT, next, undefined, opencodeMessageId("oc_text_", ev.part));
218
+ return;
219
+ }
220
+ case "tool_use": {
221
+ if (ev.part?.state?.status !== "completed")
222
+ return;
223
+ postEvent(ROLE_TOOL, formatToolUseContent(ev.part), undefined, opencodeMessageId("oc_tool_", ev.part));
224
+ return;
225
+ }
226
+ case "error": {
227
+ const msg = ev.error?.data?.message ||
228
+ ev.error?.message ||
229
+ line.trim();
230
+ if (msg)
231
+ fatalError = msg;
232
+ return;
233
+ }
234
+ case "step_start":
235
+ case "step_finish":
236
+ return;
237
+ default:
238
+ return;
239
+ }
240
+ };
172
241
  child.stdout.on("data", (chunk) => {
173
242
  buf += chunk.toString();
174
243
  const lines = buf.split("\n");
175
244
  buf = lines.pop() || "";
176
245
  for (const line of lines) {
177
- if (!line.trim())
178
- continue;
179
- const fatal = extractOpencodeFatalError(line);
180
- if (fatal) {
181
- fatalError = fatal;
182
- continue;
183
- }
184
- let content = line;
185
- let role = ROLE_ASSISTANT;
186
- try {
187
- const ev = JSON.parse(line);
188
- content = ev.content || ev.message || ev.text || JSON.stringify(ev);
189
- if (ev.type === "tool" || ev.role === "tool")
190
- role = ROLE_TOOL;
191
- if (ev.type === "status")
192
- role = ROLE_STATUS;
193
- }
194
- catch {
195
- /* plain line */
196
- }
197
- postEvent(role, content);
246
+ handleOpencodeLine(line);
198
247
  }
199
248
  });
200
249
  child.on("close", (code) => {
201
250
  if (buf.trim()) {
202
- const fatal = extractOpencodeFatalError(buf);
203
- if (fatal)
204
- fatalError = fatal;
205
- else if (!fatalError)
206
- postEvent(ROLE_ASSISTANT, buf.trim());
251
+ handleOpencodeLine(buf.trim());
207
252
  }
208
253
  const stderrFatal = extractOpencodeFatalError(err);
209
254
  if (stderrFatal)
@@ -242,57 +287,83 @@ function runOpencode(prompt, model, childEnv, postEvent) {
242
287
  return Promise.resolve({ code: errObj.status || 1, err: fatal });
243
288
  }
244
289
  }
245
- function connectInbound(backend, apiKey, sessionId, onUserMessage, onIdle, onClosed) {
290
+ function connectInboundStream(backend, apiKey, sessionId, handlers) {
246
291
  const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
247
292
  const lib = url.protocol === "https:" ? https : http;
248
- const req = lib.request({
249
- hostname: url.hostname,
250
- port: url.port || (url.protocol === "https:" ? 443 : 80),
251
- path: url.pathname + url.search,
252
- method: "GET",
253
- headers: {
254
- "TestChimp-Api-Key": apiKey,
255
- Accept: "text/event-stream",
256
- "Cache-Control": "no-cache",
257
- },
258
- }, (res) => {
259
- let buf = "";
260
- let eventName = "message";
261
- res.on("data", (chunk) => {
262
- buf += chunk.toString();
263
- const parts = buf.split("\n");
264
- buf = parts.pop() || "";
265
- for (const line of parts) {
266
- if (line.startsWith("event:")) {
267
- eventName = line.slice(6).trim() || "message";
268
- }
269
- else if (line.startsWith("data:")) {
270
- const data = line.slice(5).trim();
271
- if (eventName === "idle") {
272
- onIdle();
293
+ let stopped = false;
294
+ let reconnectTimer = null;
295
+ let reconnectDelayMs = 1000;
296
+ const scheduleReconnect = () => {
297
+ if (stopped || !handlers.shouldRun())
298
+ return;
299
+ if (reconnectTimer)
300
+ return;
301
+ reconnectTimer = setTimeout(() => {
302
+ reconnectTimer = null;
303
+ connect();
304
+ }, reconnectDelayMs);
305
+ reconnectDelayMs = Math.min(reconnectDelayMs * 2, 15_000);
306
+ };
307
+ const connect = () => {
308
+ if (stopped || !handlers.shouldRun())
309
+ return;
310
+ const req = lib.request({
311
+ hostname: url.hostname,
312
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
313
+ path: url.pathname + url.search,
314
+ method: "GET",
315
+ headers: {
316
+ "TestChimp-Api-Key": apiKey,
317
+ Accept: "text/event-stream",
318
+ "Cache-Control": "no-cache",
319
+ },
320
+ }, (res) => {
321
+ reconnectDelayMs = 1000;
322
+ let buf = "";
323
+ let eventName = "message";
324
+ res.on("data", (chunk) => {
325
+ buf += chunk.toString();
326
+ const parts = buf.split("\n");
327
+ buf = parts.pop() || "";
328
+ for (const line of parts) {
329
+ if (line.startsWith("event:")) {
330
+ eventName = line.slice(6).trim() || "message";
273
331
  }
274
- else if (eventName === "user_message" || eventName === "message") {
275
- try {
276
- const msg = JSON.parse(data);
277
- const content = msg.content || "";
278
- if (content)
279
- onUserMessage(content);
332
+ else if (line.startsWith("data:")) {
333
+ const data = line.slice(5).trim();
334
+ if (eventName === "idle") {
335
+ handlers.onIdle();
280
336
  }
281
- catch {
282
- /* ignore */
337
+ else if (eventName === "user_message" || eventName === "message") {
338
+ try {
339
+ const msg = JSON.parse(data);
340
+ handlers.onUserMessage({
341
+ id: msg.id || msg.message_id,
342
+ content: msg.content || "",
343
+ });
344
+ }
345
+ catch {
346
+ /* ignore */
347
+ }
283
348
  }
349
+ eventName = "message";
350
+ }
351
+ else if (line === "") {
352
+ eventName = "message";
284
353
  }
285
- eventName = "message";
286
- }
287
- else if (line === "") {
288
- eventName = "message";
289
354
  }
290
- }
355
+ });
356
+ res.on("end", () => scheduleReconnect());
291
357
  });
292
- res.on("end", () => onClosed());
293
- });
294
- req.on("error", () => onClosed());
295
- req.end();
358
+ req.on("error", () => scheduleReconnect());
359
+ req.end();
360
+ };
361
+ connect();
362
+ return () => {
363
+ stopped = true;
364
+ if (reconnectTimer)
365
+ clearTimeout(reconnectTimer);
366
+ };
296
367
  }
297
368
  export async function runChimphands(opts) {
298
369
  const apiKey = requireApiKey();
@@ -324,15 +395,49 @@ export async function runChimphands(opts) {
324
395
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
325
396
  const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
326
397
  const queue = [];
398
+ const seenUserMessageIds = new Set();
327
399
  let idle = false;
328
- let closed = false;
400
+ let sessionActive = true;
329
401
  let lastUserActivity = Date.now();
330
- const postEvent = (role, content, status) => {
402
+ const enqueueUserMessage = (msg) => {
403
+ const id = msg.id?.trim();
404
+ if (id) {
405
+ if (seenUserMessageIds.has(id))
406
+ return;
407
+ seenUserMessageIds.add(id);
408
+ }
409
+ const content = normalizeUserMessage(msg.content || "");
410
+ if (!content)
411
+ return;
412
+ queue.push(content);
413
+ lastUserActivity = Date.now();
414
+ idle = false;
415
+ };
416
+ const pollPendingUserMessages = async () => {
417
+ try {
418
+ const text = await postJson(backend, apiKey, "/api/chimphands/consume_pending_user_messages", {
419
+ sessionId,
420
+ });
421
+ const data = JSON.parse(text);
422
+ for (const msg of data.messages || []) {
423
+ enqueueUserMessage({
424
+ id: msg.id || msg.message_id,
425
+ content: msg.content || "",
426
+ });
427
+ }
428
+ }
429
+ catch {
430
+ // Polling is best-effort when inbound SSE misses an event.
431
+ }
432
+ };
433
+ const postEvent = (role, content, status, messageId) => {
331
434
  const body = {
332
435
  sessionId,
333
436
  role,
334
437
  content: String(content || "").slice(0, 20000),
335
438
  };
439
+ if (messageId)
440
+ body.messageId = messageId;
336
441
  if (status != null)
337
442
  body.status = status;
338
443
  postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
@@ -352,30 +457,46 @@ export async function runChimphands(opts) {
352
457
  };
353
458
  if (userId)
354
459
  childEnv.TESTCHIMP_USER_ID = userId;
355
- connectInbound(backend, apiKey, sessionId, (content) => {
356
- queue.push(ensureTestchimpPrompt(content));
357
- lastUserActivity = Date.now();
358
- }, () => {
359
- idle = true;
360
- }, () => {
361
- closed = true;
460
+ const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
461
+ onUserMessage: enqueueUserMessage,
462
+ onIdle: () => {
463
+ idle = true;
464
+ },
465
+ shouldRun: () => sessionActive,
362
466
  });
363
467
  postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
364
- let prompt = ensureTestchimpPrompt(promptInput || boot.initial_prompt || "");
468
+ let prompt = normalizeUserMessage(promptInput || boot.initial_prompt || "");
365
469
  if (boot.conversation_summary) {
366
470
  prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
367
471
  }
368
472
  for (const m of boot.pending_user_messages || []) {
369
473
  if (m?.content)
370
- queue.push(ensureTestchimpPrompt(m.content));
474
+ enqueueUserMessage({ content: m.content });
371
475
  }
372
476
  const waitForNextPrompt = () => new Promise((resolve) => {
477
+ let lastPollAt = 0;
373
478
  const tick = () => {
374
479
  if (queue.length) {
375
- resolve(ensureTestchimpPrompt(queue.shift()));
480
+ resolve(normalizeUserMessage(queue.shift()));
481
+ return;
482
+ }
483
+ const now = Date.now();
484
+ if (now - lastPollAt >= 1500) {
485
+ lastPollAt = now;
486
+ void pollPendingUserMessages().then(() => {
487
+ if (queue.length) {
488
+ resolve(normalizeUserMessage(queue.shift()));
489
+ return;
490
+ }
491
+ if (idle || now - lastUserActivity >= idleMs) {
492
+ resolve(null);
493
+ return;
494
+ }
495
+ setTimeout(tick, 500);
496
+ });
376
497
  return;
377
498
  }
378
- if (idle || closed || Date.now() - lastUserActivity >= idleMs) {
499
+ if (idle || now - lastUserActivity >= idleMs) {
379
500
  resolve(null);
380
501
  return;
381
502
  }
@@ -384,7 +505,7 @@ export async function runChimphands(opts) {
384
505
  tick();
385
506
  });
386
507
  while (prompt) {
387
- const result = await runOpencode(ensureTestchimpPrompt(prompt), opencodeModel, childEnv, postEvent);
508
+ const result = await runOpencode(prompt, opencodeModel, childEnv, postEvent);
388
509
  if (result.code !== 0) {
389
510
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
390
511
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -417,6 +538,8 @@ export async function runChimphands(opts) {
417
538
  idle = false;
418
539
  prompt = (await waitForNextPrompt()) || "";
419
540
  }
541
+ sessionActive = false;
542
+ stopInbound();
420
543
  console.error("ChimpHands session idle — no user input before timeout; completing.");
421
544
  complete(STATUS_IDLE);
422
545
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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",