@testchimp/cli 0.1.35 → 0.1.37

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChimpHands GitHub Actions bridge: bootstrap → OpenCode → inbound SSE turns.
3
3
  * Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
4
- * Does not write mcp.json — CLI/skill use process env.
4
+ * Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
5
5
  */
6
6
  type RunOptions = {
7
7
  sessionId: string;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChimpHands GitHub Actions bridge: bootstrap → OpenCode → inbound SSE turns.
3
3
  * Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
4
- * Does not write mcp.json — CLI/skill use process env.
4
+ * Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
5
5
  */
6
6
  import { spawn, execFileSync } from "node:child_process";
7
7
  import { mkdirSync, writeFileSync } from "node:fs";
@@ -16,6 +16,13 @@ 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";
25
+ }
19
26
  function apiHeaders(apiKey) {
20
27
  return {
21
28
  "Content-Type": "application/json",
@@ -35,25 +42,111 @@ async function postJson(backend, apiKey, path, body) {
35
42
  return text;
36
43
  }
37
44
  function postJsonFireAndForget(backend, apiKey, path, body) {
38
- void postJson(backend, apiKey, path, body).catch(() => {
39
- /* best-effort agent telemetry */
45
+ void postJson(backend, apiKey, path, body).catch((err) => {
46
+ const detail = err instanceof Error ? err.message : String(err);
47
+ console.error(`ChimpHands API telemetry failed ${path}: ${detail}`);
40
48
  });
41
49
  }
50
+ const TESTCHIMP_PROVIDER_ID = "testchimp";
51
+ function resolveOpencodeModelId(boot) {
52
+ const raw = (boot.llm_model || "gpt-4o-mini").trim();
53
+ const modelId = raw.includes("/") ? raw.split("/").pop() || "gpt-4o-mini" : raw;
54
+ return modelId;
55
+ }
56
+ function resolveOpencodeModel(boot) {
57
+ return `${TESTCHIMP_PROVIDER_ID}/${resolveOpencodeModelId(boot)}`;
58
+ }
59
+ function extractOpencodeFatalError(raw) {
60
+ const line = raw.trim();
61
+ if (!line)
62
+ return null;
63
+ try {
64
+ const ev = JSON.parse(line);
65
+ if (ev.type === "error" || ev.name === "UnknownError") {
66
+ const msg = ev.data?.message || ev.message || line;
67
+ const ref = ev.data?.ref ? ` (ref ${ev.data.ref})` : "";
68
+ return `${msg}${ref}`;
69
+ }
70
+ }
71
+ catch {
72
+ /* plain text */
73
+ }
74
+ if (line.includes("Unexpected server error") && line.includes("UnknownError")) {
75
+ return line;
76
+ }
77
+ const errorLine = line.match(/^Error:\s*(.+)$/i);
78
+ if (errorLine?.[1]?.trim()) {
79
+ return errorLine[1].trim();
80
+ }
81
+ if (/not found/i.test(line) && line.length < 240) {
82
+ return line.trim();
83
+ }
84
+ return null;
85
+ }
86
+ function summarizeOpencodeFailure(stderr, stdout, exitCode) {
87
+ for (const chunk of [stderr, stdout]) {
88
+ for (const line of chunk.split("\n")) {
89
+ const fatal = extractOpencodeFatalError(line);
90
+ if (fatal)
91
+ return fatal;
92
+ }
93
+ }
94
+ const merged = `${stderr}\n${stdout}`.trim();
95
+ if (merged) {
96
+ const errorLines = merged
97
+ .split("\n")
98
+ .map((l) => l.trim())
99
+ .filter((l) => /^error:/i.test(l) || /not found/i.test(l));
100
+ if (errorLines.length)
101
+ return errorLines[errorLines.length - 1].replace(/^error:\s*/i, "").trim();
102
+ return merged.slice(0, 1200);
103
+ }
104
+ return exitCode ? `opencode exited with code ${exitCode}` : "opencode failed";
105
+ }
42
106
  function writeOpencodeConfig(backend, apiKey, boot) {
43
107
  const llmBase = (boot.llm_base_url || `${backend}/v1`).replace(/\/$/, "");
44
108
  const llmKey = apiKey || boot.llm_api_key || "";
45
- const llmModel = boot.llm_model || "gpt-4o-mini";
109
+ const modelId = resolveOpencodeModelId(boot);
110
+ const model = `${TESTCHIMP_PROVIDER_ID}/${modelId}`;
111
+ const mcpEnv = {
112
+ TESTCHIMP_API_KEY: apiKey,
113
+ TESTCHIMP_BACKEND_URL: backend,
114
+ };
115
+ const serviceUserId = boot.chimphands_service_account_user_id?.trim();
116
+ if (serviceUserId) {
117
+ mcpEnv.TESTCHIMP_USER_ID = serviceUserId;
118
+ }
46
119
  writeFileSync("opencode.json", JSON.stringify({
47
- model: llmModel,
120
+ $schema: "https://opencode.ai/config.json",
121
+ model,
122
+ autoupdate: false,
48
123
  provider: {
49
- openai: {
50
- apiKey: llmKey,
51
- baseURL: llmBase,
124
+ [TESTCHIMP_PROVIDER_ID]: {
125
+ npm: "@ai-sdk/openai-compatible",
126
+ name: "TestChimp",
127
+ options: {
128
+ apiKey: llmKey,
129
+ baseURL: llmBase,
130
+ },
131
+ models: {
132
+ [modelId]: {
133
+ name: modelId,
134
+ },
135
+ },
136
+ },
137
+ },
138
+ mcp: {
139
+ testchimp: {
140
+ type: "local",
141
+ enabled: true,
142
+ command: ["npx", "-y", "@testchimp/cli@latest", "mcp"],
143
+ environment: mcpEnv,
52
144
  },
53
145
  },
54
146
  }, null, 2));
147
+ return model;
55
148
  }
56
- function runOpencode(prompt, childEnv, postEvent) {
149
+ function runOpencode(prompt, model, childEnv, postEvent) {
57
150
  const help = (() => {
58
151
  try {
59
152
  return execFileSync("opencode", ["run", "--help"], { encoding: "utf8", env: childEnv });
@@ -63,8 +156,9 @@ function runOpencode(prompt, childEnv, postEvent) {
63
156
  }
64
157
  })();
65
158
  const useJson = help.includes("--format");
159
+ const baseArgs = ["run", prompt, "--model", model];
66
160
  if (useJson) {
67
- const child = spawn("opencode", ["run", prompt, "--format", "json"], {
161
+ const child = spawn("opencode", [...baseArgs, "--format", "json"], {
68
162
  stdio: ["ignore", "pipe", "pipe"],
69
163
  env: childEnv,
70
164
  });
@@ -74,6 +168,7 @@ function runOpencode(prompt, childEnv, postEvent) {
74
168
  });
75
169
  return new Promise((resolve) => {
76
170
  let buf = "";
171
+ let fatalError = null;
77
172
  child.stdout.on("data", (chunk) => {
78
173
  buf += chunk.toString();
79
174
  const lines = buf.split("\n");
@@ -81,6 +176,11 @@ function runOpencode(prompt, childEnv, postEvent) {
81
176
  for (const line of lines) {
82
177
  if (!line.trim())
83
178
  continue;
179
+ const fatal = extractOpencodeFatalError(line);
180
+ if (fatal) {
181
+ fatalError = fatal;
182
+ continue;
183
+ }
84
184
  let content = line;
85
185
  let role = ROLE_ASSISTANT;
86
186
  try {
@@ -98,27 +198,48 @@ function runOpencode(prompt, childEnv, postEvent) {
98
198
  }
99
199
  });
100
200
  child.on("close", (code) => {
101
- if (buf.trim())
102
- postEvent(ROLE_ASSISTANT, buf.trim());
201
+ if (buf.trim()) {
202
+ const fatal = extractOpencodeFatalError(buf);
203
+ if (fatal)
204
+ fatalError = fatal;
205
+ else if (!fatalError)
206
+ postEvent(ROLE_ASSISTANT, buf.trim());
207
+ }
208
+ const stderrFatal = extractOpencodeFatalError(err);
209
+ if (stderrFatal)
210
+ fatalError = stderrFatal;
211
+ if (fatalError) {
212
+ resolve({ code: 1, err: fatalError });
213
+ return;
214
+ }
215
+ if (code != null && code !== 0) {
216
+ resolve({ code, err: summarizeOpencodeFailure(err, buf, code) });
217
+ return;
218
+ }
103
219
  resolve({ code: code == null ? 1 : code, err });
104
220
  });
105
221
  });
106
222
  }
107
223
  try {
108
- const out = execFileSync("opencode", ["run", prompt], {
224
+ const out = execFileSync("opencode", baseArgs, {
109
225
  encoding: "utf8",
110
226
  maxBuffer: 20 * 1024 * 1024,
111
227
  stdio: ["ignore", "pipe", "pipe"],
112
228
  env: childEnv,
113
229
  });
230
+ const fatal = extractOpencodeFatalError(out);
231
+ if (fatal)
232
+ return Promise.resolve({ code: 1, err: fatal });
114
233
  if (out)
115
234
  postEvent(ROLE_ASSISTANT, out);
116
235
  return Promise.resolve({ code: 0, err: "" });
117
236
  }
118
237
  catch (e) {
119
238
  const errObj = e;
120
- const err = (errObj.stderr && errObj.stderr.toString()) || errObj.message || "opencode failed";
121
- return Promise.resolve({ code: errObj.status || 1, err });
239
+ const stderr = errObj.stderr?.toString() || "";
240
+ const stdout = errObj.stdout?.toString() || "";
241
+ const fatal = summarizeOpencodeFailure(stderr, stdout, errObj.status ?? 1);
242
+ return Promise.resolve({ code: errObj.status || 1, err: fatal });
122
243
  }
123
244
  }
124
245
  function connectInbound(backend, apiKey, sessionId, onUserMessage, onIdle, onClosed) {
@@ -184,15 +305,23 @@ export async function runChimphands(opts) {
184
305
  }
185
306
  const promptInput = (opts.prompt ?? process.env.PROMPT ?? "").trim();
186
307
  const bootText = await postJson(backend, apiKey, "/api/chimphands/bootstrap", {
187
- session_id: sessionId,
308
+ sessionId,
188
309
  });
189
310
  const boot = JSON.parse(bootText);
311
+ const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
312
+ if (githubRunId) {
313
+ postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", {
314
+ sessionId,
315
+ githubRunId,
316
+ });
317
+ }
190
318
  const userId = boot.chimphands_service_account_user_id || "";
191
319
  if (userId) {
192
320
  process.env.TESTCHIMP_USER_ID = userId;
193
321
  }
194
322
  mkdirSync(".opencode", { recursive: true });
195
- writeOpencodeConfig(backend, apiKey, boot);
323
+ const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
324
+ console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
196
325
  const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
197
326
  const queue = [];
198
327
  let idle = false;
@@ -200,7 +329,7 @@ export async function runChimphands(opts) {
200
329
  let lastUserActivity = Date.now();
201
330
  const postEvent = (role, content, status) => {
202
331
  const body = {
203
- session_id: sessionId,
332
+ sessionId,
204
333
  role,
205
334
  content: String(content || "").slice(0, 20000),
206
335
  };
@@ -209,9 +338,11 @@ export async function runChimphands(opts) {
209
338
  postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
210
339
  };
211
340
  const complete = (status, errorMessage) => {
212
- const body = { session_id: sessionId, status };
341
+ const body = { sessionId, status };
213
342
  if (errorMessage)
214
- body.error_message = String(errorMessage).slice(0, 4000);
343
+ body.errorMessage = String(errorMessage).slice(0, 4000);
344
+ if (githubRunId)
345
+ body.githubRunId = githubRunId;
215
346
  postJsonFireAndForget(backend, apiKey, "/api/chimphands/complete_session", body);
216
347
  };
217
348
  const childEnv = {
@@ -222,7 +353,7 @@ export async function runChimphands(opts) {
222
353
  if (userId)
223
354
  childEnv.TESTCHIMP_USER_ID = userId;
224
355
  connectInbound(backend, apiKey, sessionId, (content) => {
225
- queue.push(content);
356
+ queue.push(ensureTestchimpPrompt(content));
226
357
  lastUserActivity = Date.now();
227
358
  }, () => {
228
359
  idle = true;
@@ -230,18 +361,18 @@ export async function runChimphands(opts) {
230
361
  closed = true;
231
362
  });
232
363
  postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
233
- let prompt = promptInput || boot.initial_prompt || "";
364
+ let prompt = ensureTestchimpPrompt(promptInput || boot.initial_prompt || "");
234
365
  if (boot.conversation_summary) {
235
366
  prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
236
367
  }
237
368
  for (const m of boot.pending_user_messages || []) {
238
369
  if (m?.content)
239
- queue.push(m.content);
370
+ queue.push(ensureTestchimpPrompt(m.content));
240
371
  }
241
372
  const waitForNextPrompt = () => new Promise((resolve) => {
242
373
  const tick = () => {
243
374
  if (queue.length) {
244
- resolve(queue.shift());
375
+ resolve(ensureTestchimpPrompt(queue.shift()));
245
376
  return;
246
377
  }
247
378
  if (idle || closed || Date.now() - lastUserActivity >= idleMs) {
@@ -253,14 +384,39 @@ export async function runChimphands(opts) {
253
384
  tick();
254
385
  });
255
386
  while (prompt) {
256
- const result = await runOpencode(prompt, childEnv, postEvent);
387
+ const result = await runOpencode(ensureTestchimpPrompt(prompt), opencodeModel, childEnv, postEvent);
257
388
  if (result.code !== 0) {
258
- complete(STATUS_FAILED, result.err || "opencode failed");
259
- process.exitCode = result.code || 1;
260
- return;
389
+ const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
390
+ console.error(`ChimpHands OpenCode failed: ${errMsg}`);
391
+ try {
392
+ await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
393
+ sessionId,
394
+ role: ROLE_STATUS,
395
+ content: errMsg,
396
+ status: STATUS_FAILED,
397
+ githubRunId: githubRunId || undefined,
398
+ });
399
+ await postJson(backend, apiKey, "/api/chimphands/complete_session", {
400
+ sessionId,
401
+ status: STATUS_FAILED,
402
+ errorMessage: errMsg,
403
+ githubRunId: githubRunId || undefined,
404
+ });
405
+ }
406
+ catch (reportErr) {
407
+ const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
408
+ console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
409
+ postEvent(ROLE_STATUS, errMsg, STATUS_FAILED);
410
+ complete(STATUS_FAILED, errMsg);
411
+ }
412
+ process.exit(result.code || 1);
261
413
  }
262
414
  postEvent(ROLE_STATUS, "Waiting for user input", STATUS_WAITING_USER);
415
+ // Idle countdown starts when the agent finishes a turn, not at job bootstrap.
416
+ lastUserActivity = Date.now();
417
+ idle = false;
263
418
  prompt = (await waitForNextPrompt()) || "";
264
419
  }
420
+ console.error("ChimpHands session idle — no user input before timeout; completing.");
265
421
  complete(STATUS_IDLE);
266
422
  }
@@ -1578,7 +1578,7 @@ export function buildCliProgram() {
1578
1578
  catch (e) {
1579
1579
  const msg = e instanceof Error ? e.message : String(e);
1580
1580
  console.error(`[testchimp chimphands] ${msg}`);
1581
- process.exitCode = 1;
1581
+ process.exit(1);
1582
1582
  }
1583
1583
  });
1584
1584
  program.on("--help", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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",