@testchimp/cli 0.1.38 → 0.1.40

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.
@@ -11,17 +11,14 @@ import { URL } from "node:url";
11
11
  import { getBackendUrl, requireApiKey } from "../core/client.js";
12
12
  const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
13
13
  const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
14
+ const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
14
15
  const ROLE_STATUS = "CHIMPHANDS_MESSAGE_ROLE_STATUS";
15
16
  const STATUS_RUNNING = "CHIMPHANDS_SESSION_STATUS_RUNNING";
16
17
  const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
17
18
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
18
19
  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";
20
+ function normalizeUserMessage(content) {
21
+ return content.trim();
25
22
  }
26
23
  function apiHeaders(apiKey) {
27
24
  return {
@@ -221,6 +218,21 @@ function runOpencode(prompt, model, childEnv, postEvent) {
221
218
  postEvent(ROLE_ASSISTANT, next, undefined, opencodeMessageId("oc_text_", ev.part));
222
219
  return;
223
220
  }
221
+ case "reasoning": {
222
+ const chunk = ev.part?.text;
223
+ if (!chunk)
224
+ return;
225
+ const partId = ev.part?.id || ev.part?.messageID;
226
+ if (!partId) {
227
+ postEvent(ROLE_REASONING, chunk);
228
+ return;
229
+ }
230
+ const reasoningKey = `reasoning:${partId}`;
231
+ const next = (textByPartId.get(reasoningKey) || "") + chunk;
232
+ textByPartId.set(reasoningKey, next);
233
+ postEvent(ROLE_REASONING, next, undefined, opencodeMessageId("oc_reasoning_", ev.part));
234
+ return;
235
+ }
224
236
  case "tool_use": {
225
237
  if (ev.part?.state?.status !== "completed")
226
238
  return;
@@ -291,57 +303,83 @@ function runOpencode(prompt, model, childEnv, postEvent) {
291
303
  return Promise.resolve({ code: errObj.status || 1, err: fatal });
292
304
  }
293
305
  }
294
- function connectInbound(backend, apiKey, sessionId, onUserMessage, onIdle, onClosed) {
306
+ function connectInboundStream(backend, apiKey, sessionId, handlers) {
295
307
  const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
296
308
  const lib = url.protocol === "https:" ? https : http;
297
- const req = lib.request({
298
- hostname: url.hostname,
299
- port: url.port || (url.protocol === "https:" ? 443 : 80),
300
- path: url.pathname + url.search,
301
- method: "GET",
302
- headers: {
303
- "TestChimp-Api-Key": apiKey,
304
- Accept: "text/event-stream",
305
- "Cache-Control": "no-cache",
306
- },
307
- }, (res) => {
308
- let buf = "";
309
- let eventName = "message";
310
- res.on("data", (chunk) => {
311
- buf += chunk.toString();
312
- const parts = buf.split("\n");
313
- buf = parts.pop() || "";
314
- for (const line of parts) {
315
- if (line.startsWith("event:")) {
316
- eventName = line.slice(6).trim() || "message";
317
- }
318
- else if (line.startsWith("data:")) {
319
- const data = line.slice(5).trim();
320
- if (eventName === "idle") {
321
- onIdle();
309
+ let stopped = false;
310
+ let reconnectTimer = null;
311
+ let reconnectDelayMs = 1000;
312
+ const scheduleReconnect = () => {
313
+ if (stopped || !handlers.shouldRun())
314
+ return;
315
+ if (reconnectTimer)
316
+ return;
317
+ reconnectTimer = setTimeout(() => {
318
+ reconnectTimer = null;
319
+ connect();
320
+ }, reconnectDelayMs);
321
+ reconnectDelayMs = Math.min(reconnectDelayMs * 2, 15_000);
322
+ };
323
+ const connect = () => {
324
+ if (stopped || !handlers.shouldRun())
325
+ return;
326
+ const req = lib.request({
327
+ hostname: url.hostname,
328
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
329
+ path: url.pathname + url.search,
330
+ method: "GET",
331
+ headers: {
332
+ "TestChimp-Api-Key": apiKey,
333
+ Accept: "text/event-stream",
334
+ "Cache-Control": "no-cache",
335
+ },
336
+ }, (res) => {
337
+ reconnectDelayMs = 1000;
338
+ let buf = "";
339
+ let eventName = "message";
340
+ res.on("data", (chunk) => {
341
+ buf += chunk.toString();
342
+ const parts = buf.split("\n");
343
+ buf = parts.pop() || "";
344
+ for (const line of parts) {
345
+ if (line.startsWith("event:")) {
346
+ eventName = line.slice(6).trim() || "message";
322
347
  }
323
- else if (eventName === "user_message" || eventName === "message") {
324
- try {
325
- const msg = JSON.parse(data);
326
- const content = msg.content || "";
327
- if (content)
328
- onUserMessage(content);
348
+ else if (line.startsWith("data:")) {
349
+ const data = line.slice(5).trim();
350
+ if (eventName === "idle") {
351
+ handlers.onIdle();
329
352
  }
330
- catch {
331
- /* ignore */
353
+ else if (eventName === "user_message" || eventName === "message") {
354
+ try {
355
+ const msg = JSON.parse(data);
356
+ handlers.onUserMessage({
357
+ id: msg.id || msg.message_id,
358
+ content: msg.content || "",
359
+ });
360
+ }
361
+ catch {
362
+ /* ignore */
363
+ }
332
364
  }
365
+ eventName = "message";
366
+ }
367
+ else if (line === "") {
368
+ eventName = "message";
333
369
  }
334
- eventName = "message";
335
- }
336
- else if (line === "") {
337
- eventName = "message";
338
370
  }
339
- }
371
+ });
372
+ res.on("end", () => scheduleReconnect());
340
373
  });
341
- res.on("end", () => onClosed());
342
- });
343
- req.on("error", () => onClosed());
344
- req.end();
374
+ req.on("error", () => scheduleReconnect());
375
+ req.end();
376
+ };
377
+ connect();
378
+ return () => {
379
+ stopped = true;
380
+ if (reconnectTimer)
381
+ clearTimeout(reconnectTimer);
382
+ };
345
383
  }
346
384
  export async function runChimphands(opts) {
347
385
  const apiKey = requireApiKey();
@@ -373,9 +411,41 @@ export async function runChimphands(opts) {
373
411
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
374
412
  const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
375
413
  const queue = [];
414
+ const seenUserMessageIds = new Set();
376
415
  let idle = false;
377
- let closed = false;
416
+ let sessionActive = true;
378
417
  let lastUserActivity = Date.now();
418
+ const enqueueUserMessage = (msg) => {
419
+ const id = msg.id?.trim();
420
+ if (id) {
421
+ if (seenUserMessageIds.has(id))
422
+ return;
423
+ seenUserMessageIds.add(id);
424
+ }
425
+ const content = normalizeUserMessage(msg.content || "");
426
+ if (!content)
427
+ return;
428
+ queue.push(content);
429
+ lastUserActivity = Date.now();
430
+ idle = false;
431
+ };
432
+ const pollPendingUserMessages = async () => {
433
+ try {
434
+ const text = await postJson(backend, apiKey, "/api/chimphands/consume_pending_user_messages", {
435
+ sessionId,
436
+ });
437
+ const data = JSON.parse(text);
438
+ for (const msg of data.messages || []) {
439
+ enqueueUserMessage({
440
+ id: msg.id || msg.message_id,
441
+ content: msg.content || "",
442
+ });
443
+ }
444
+ }
445
+ catch {
446
+ // Polling is best-effort when inbound SSE misses an event.
447
+ }
448
+ };
379
449
  const postEvent = (role, content, status, messageId) => {
380
450
  const body = {
381
451
  sessionId,
@@ -403,30 +473,46 @@ export async function runChimphands(opts) {
403
473
  };
404
474
  if (userId)
405
475
  childEnv.TESTCHIMP_USER_ID = userId;
406
- connectInbound(backend, apiKey, sessionId, (content) => {
407
- queue.push(ensureTestchimpPrompt(content));
408
- lastUserActivity = Date.now();
409
- }, () => {
410
- idle = true;
411
- }, () => {
412
- closed = true;
476
+ const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
477
+ onUserMessage: enqueueUserMessage,
478
+ onIdle: () => {
479
+ idle = true;
480
+ },
481
+ shouldRun: () => sessionActive,
413
482
  });
414
483
  postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
415
- let prompt = ensureTestchimpPrompt(promptInput || boot.initial_prompt || "");
484
+ let prompt = normalizeUserMessage(promptInput || boot.initial_prompt || "");
416
485
  if (boot.conversation_summary) {
417
486
  prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
418
487
  }
419
488
  for (const m of boot.pending_user_messages || []) {
420
489
  if (m?.content)
421
- queue.push(ensureTestchimpPrompt(m.content));
490
+ enqueueUserMessage({ content: m.content });
422
491
  }
423
492
  const waitForNextPrompt = () => new Promise((resolve) => {
493
+ let lastPollAt = 0;
424
494
  const tick = () => {
425
495
  if (queue.length) {
426
- resolve(ensureTestchimpPrompt(queue.shift()));
496
+ resolve(normalizeUserMessage(queue.shift()));
497
+ return;
498
+ }
499
+ const now = Date.now();
500
+ if (now - lastPollAt >= 1500) {
501
+ lastPollAt = now;
502
+ void pollPendingUserMessages().then(() => {
503
+ if (queue.length) {
504
+ resolve(normalizeUserMessage(queue.shift()));
505
+ return;
506
+ }
507
+ if (idle || now - lastUserActivity >= idleMs) {
508
+ resolve(null);
509
+ return;
510
+ }
511
+ setTimeout(tick, 500);
512
+ });
427
513
  return;
428
514
  }
429
- if (idle || closed || Date.now() - lastUserActivity >= idleMs) {
515
+ if (idle || now - lastUserActivity >= idleMs) {
430
516
  resolve(null);
431
517
  return;
432
518
  }
@@ -435,7 +521,7 @@ export async function runChimphands(opts) {
435
521
  tick();
436
522
  });
437
523
  while (prompt) {
438
- const result = await runOpencode(ensureTestchimpPrompt(prompt), opencodeModel, childEnv, postEvent);
524
+ const result = await runOpencode(prompt, opencodeModel, childEnv, postEvent);
439
525
  if (result.code !== 0) {
440
526
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
441
527
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -468,6 +554,8 @@ export async function runChimphands(opts) {
468
554
  idle = false;
469
555
  prompt = (await waitForNextPrompt()) || "";
470
556
  }
557
+ sessionActive = false;
558
+ stopInbound();
471
559
  console.error("ChimpHands session idle — no user input before timeout; completing.");
472
560
  complete(STATUS_IDLE);
473
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
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",