@testchimp/cli 0.1.39 → 0.1.41
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.d.ts +7 -0
- package/dist/chimphands/run.js +375 -141
- package/dist/cli/program.js +22 -0
- package/package.json +1 -1
package/dist/chimphands/run.d.ts
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
|
|
4
4
|
* Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
|
|
5
5
|
*/
|
|
6
|
+
export type ReportWorkingBranchOptions = {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
pullRequestUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Agent/CLI hook: persist the conversation working branch (+ optional PR) for UI + later turns. */
|
|
12
|
+
export declare function reportWorkingBranch(opts: ReportWorkingBranchOptions): Promise<void>;
|
|
6
13
|
type RunOptions = {
|
|
7
14
|
sessionId: string;
|
|
8
15
|
prompt?: string;
|
package/dist/chimphands/run.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
|
|
4
4
|
* Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
|
|
5
5
|
*/
|
|
6
|
-
import { spawn
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
7
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import http from "node:http";
|
|
9
9
|
import https from "node:https";
|
|
@@ -11,14 +11,57 @@ 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";
|
|
20
|
+
const OPENCODE_AGENT_ID = "chimphands";
|
|
21
|
+
const STREAM_POST_MIN_INTERVAL_MS = 60;
|
|
22
|
+
const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's cloud coding agent running in GitHub Actions.
|
|
23
|
+
|
|
24
|
+
## Repo changes (mandatory)
|
|
25
|
+
- NEVER commit or push directly to the default branch (main/master).
|
|
26
|
+
- This conversation uses ONE working branch and ONE pull request. Reuse them for all follow-up work in this chat.
|
|
27
|
+
- If bootstrap lists a working branch, checkout that branch and push additional commits there — update the same PR.
|
|
28
|
+
- Only create a NEW branch/PR when (a) no working branch exists yet for this conversation, or (b) the prior PR was merged/closed (verify with \`gh pr view\`).
|
|
29
|
+
- Branch names MUST start with \`testchimp-\` or \`chimphands-\`.
|
|
30
|
+
- After creating a branch or opening a PR, IMMEDIATELY run:
|
|
31
|
+
\`testchimp chimphands report-branch --branch <name> [--pr-url <url>]\`
|
|
32
|
+
- Tell the user which branch you are on and include the PR URL when available.
|
|
33
|
+
|
|
34
|
+
## TestChimp workflows (/testchimp …)
|
|
35
|
+
- Load and follow the \`testchimp\` skill under \`.agents/skills/testchimp/SKILL.md\`.
|
|
36
|
+
- For any /testchimp command: use TestChimp MCP tools (preferred) or \`testchimp\` CLI — never invent API results.
|
|
37
|
+
- Follow plan → explicit user approval → execute. Do not skip MCP calls or claim done without tool evidence.
|
|
38
|
+
- Export \`TESTCHIMP_EXECUTION_SOURCE=CLOUD_AGENT\` before Playwright/Mobilewright runs.
|
|
39
|
+
|
|
40
|
+
## Honesty
|
|
41
|
+
- If MCP/tools fail, report the error. Never narrate success without tool output or a PR link when repo changes were needed.`;
|
|
19
42
|
function normalizeUserMessage(content) {
|
|
20
43
|
return content.trim();
|
|
21
44
|
}
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
/** Agent/CLI hook: persist the conversation working branch (+ optional PR) for UI + later turns. */
|
|
49
|
+
export async function reportWorkingBranch(opts) {
|
|
50
|
+
const apiKey = requireApiKey();
|
|
51
|
+
const backend = getBackendUrl();
|
|
52
|
+
const branch = opts.branch.trim();
|
|
53
|
+
if (!branch) {
|
|
54
|
+
throw new Error("branch is required");
|
|
55
|
+
}
|
|
56
|
+
const body = {
|
|
57
|
+
sessionId: opts.sessionId.trim(),
|
|
58
|
+
workingBranch: branch,
|
|
59
|
+
};
|
|
60
|
+
const pr = opts.pullRequestUrl?.trim();
|
|
61
|
+
if (pr)
|
|
62
|
+
body.pullRequestUrl = pr;
|
|
63
|
+
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", body);
|
|
64
|
+
}
|
|
22
65
|
function apiHeaders(apiKey) {
|
|
23
66
|
return {
|
|
24
67
|
"Content-Type": "application/json",
|
|
@@ -37,11 +80,70 @@ async function postJson(backend, apiKey, path, body) {
|
|
|
37
80
|
}
|
|
38
81
|
return text;
|
|
39
82
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
83
|
+
/** Serializes post_agent_event calls so streaming chunks commit and fan out in order. */
|
|
84
|
+
class AgentEventPoster {
|
|
85
|
+
backend;
|
|
86
|
+
apiKey;
|
|
87
|
+
sessionId;
|
|
88
|
+
chain = Promise.resolve();
|
|
89
|
+
lastStreamPostAt = 0;
|
|
90
|
+
constructor(backend, apiKey, sessionId) {
|
|
91
|
+
this.backend = backend;
|
|
92
|
+
this.apiKey = apiKey;
|
|
93
|
+
this.sessionId = sessionId;
|
|
94
|
+
}
|
|
95
|
+
enqueue(role, content, opts) {
|
|
96
|
+
const body = {
|
|
97
|
+
sessionId: this.sessionId,
|
|
98
|
+
role,
|
|
99
|
+
content: String(content || "").slice(0, 20000),
|
|
100
|
+
};
|
|
101
|
+
if (opts?.messageId)
|
|
102
|
+
body.messageId = opts.messageId;
|
|
103
|
+
if (opts?.status != null)
|
|
104
|
+
body.status = opts.status;
|
|
105
|
+
if (opts?.opencodeSessionId)
|
|
106
|
+
body.opencodeSessionId = opts.opencodeSessionId;
|
|
107
|
+
if (opts?.workingBranch)
|
|
108
|
+
body.workingBranch = opts.workingBranch;
|
|
109
|
+
if (opts?.pullRequestUrl)
|
|
110
|
+
body.pullRequestUrl = opts.pullRequestUrl;
|
|
111
|
+
this.chain = this.chain.then(async () => {
|
|
112
|
+
if (opts?.throttle) {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
|
|
115
|
+
if (wait > 0)
|
|
116
|
+
await sleep(wait);
|
|
117
|
+
this.lastStreamPostAt = Date.now();
|
|
118
|
+
}
|
|
119
|
+
await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
|
|
120
|
+
});
|
|
121
|
+
return this.chain;
|
|
122
|
+
}
|
|
123
|
+
fireAndForget(role, content, opts) {
|
|
124
|
+
void this.enqueue(role, content, opts).catch((err) => {
|
|
125
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
126
|
+
console.error(`ChimpHands API telemetry failed: ${detail}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
flush() {
|
|
130
|
+
return this.chain;
|
|
131
|
+
}
|
|
132
|
+
reportWorkingBranch(branch, pullRequestUrl) {
|
|
133
|
+
this.chain = this.chain.then(async () => {
|
|
134
|
+
const body = {
|
|
135
|
+
sessionId: this.sessionId,
|
|
136
|
+
workingBranch: branch,
|
|
137
|
+
};
|
|
138
|
+
if (pullRequestUrl?.trim())
|
|
139
|
+
body.pullRequestUrl = pullRequestUrl.trim();
|
|
140
|
+
await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
|
|
141
|
+
});
|
|
142
|
+
void this.chain.catch((err) => {
|
|
143
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
144
|
+
console.error(`ChimpHands report-branch failed: ${detail}`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
45
147
|
}
|
|
46
148
|
const TESTCHIMP_PROVIDER_ID = "testchimp";
|
|
47
149
|
function resolveOpencodeModelId(boot) {
|
|
@@ -93,10 +195,18 @@ function parseOpencodeEvent(line) {
|
|
|
93
195
|
}
|
|
94
196
|
function formatToolUseContent(part) {
|
|
95
197
|
const title = part.state?.title || part.tool || "tool";
|
|
198
|
+
const status = part.state?.status?.trim();
|
|
199
|
+
const input = part.state?.input;
|
|
200
|
+
const inputText = input && Object.keys(input).length
|
|
201
|
+
? `\nInput: ${JSON.stringify(input).slice(0, 4000)}`
|
|
202
|
+
: "";
|
|
96
203
|
const output = part.state?.output?.trim();
|
|
204
|
+
const statusLine = status ? `[${title}] (${status})` : `[${title}]`;
|
|
97
205
|
if (output)
|
|
98
|
-
return
|
|
99
|
-
|
|
206
|
+
return `${statusLine}\n${output}`;
|
|
207
|
+
if (inputText)
|
|
208
|
+
return `${statusLine}${inputText}`;
|
|
209
|
+
return statusLine;
|
|
100
210
|
}
|
|
101
211
|
function opencodeMessageId(prefix, part) {
|
|
102
212
|
const raw = part?.id || part?.messageID;
|
|
@@ -124,6 +234,42 @@ function summarizeOpencodeFailure(stderr, stdout, exitCode) {
|
|
|
124
234
|
}
|
|
125
235
|
return exitCode ? `opencode exited with code ${exitCode}` : "opencode failed";
|
|
126
236
|
}
|
|
237
|
+
function isMissingOpencodeSessionError(message) {
|
|
238
|
+
const m = message.toLowerCase();
|
|
239
|
+
return ((m.includes("session") && m.includes("not found")) ||
|
|
240
|
+
m.includes("unknown session") ||
|
|
241
|
+
m.includes("invalid session"));
|
|
242
|
+
}
|
|
243
|
+
function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
|
|
244
|
+
const parts = [];
|
|
245
|
+
if (workingBranch?.trim()) {
|
|
246
|
+
parts.push("## Conversation working branch (reuse for this thread)", `Branch: \`${workingBranch.trim()}\``, pullRequestUrl?.trim() ? `PR: ${pullRequestUrl.trim()}` : "", "Checkout this branch, commit and push here. Do NOT open a new PR unless the one above was merged/closed.", "");
|
|
247
|
+
}
|
|
248
|
+
const task = normalizeUserMessage(userPrompt);
|
|
249
|
+
if (isNewOpencodeSession && conversationSummary.trim()) {
|
|
250
|
+
parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
|
|
251
|
+
return parts.filter(Boolean).join("\n");
|
|
252
|
+
}
|
|
253
|
+
if (parts.length) {
|
|
254
|
+
parts.push(`Current task:\n${task}`);
|
|
255
|
+
return parts.filter(Boolean).join("\n");
|
|
256
|
+
}
|
|
257
|
+
return task;
|
|
258
|
+
}
|
|
259
|
+
function detectWorkingBranchFromToolOutput(output) {
|
|
260
|
+
const text = output.trim();
|
|
261
|
+
if (!text)
|
|
262
|
+
return {};
|
|
263
|
+
const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
|
|
264
|
+
const checkoutMatch = text.match(/checkout\s+-b\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
265
|
+
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
266
|
+
const branchMatch = text.match(/branch['":\s]+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
267
|
+
const branch = (checkoutMatch?.[1] || pushMatch?.[1] || branchMatch?.[1])?.replace(/[`'"]/g, "");
|
|
268
|
+
return {
|
|
269
|
+
branch,
|
|
270
|
+
pullRequestUrl: prMatch?.[0],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
127
273
|
function writeOpencodeConfig(backend, apiKey, boot) {
|
|
128
274
|
const llmBase = (boot.llm_base_url || `${backend}/v1`).replace(/\/$/, "");
|
|
129
275
|
const llmKey = apiKey || boot.llm_api_key || "";
|
|
@@ -132,6 +278,7 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
132
278
|
const mcpEnv = {
|
|
133
279
|
TESTCHIMP_API_KEY: apiKey,
|
|
134
280
|
TESTCHIMP_BACKEND_URL: backend,
|
|
281
|
+
TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
|
|
135
282
|
};
|
|
136
283
|
const serviceUserId = boot.chimphands_service_account_user_id?.trim();
|
|
137
284
|
if (serviceUserId) {
|
|
@@ -140,6 +287,7 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
140
287
|
writeFileSync("opencode.json", JSON.stringify({
|
|
141
288
|
$schema: "https://opencode.ai/config.json",
|
|
142
289
|
model,
|
|
290
|
+
default_agent: OPENCODE_AGENT_ID,
|
|
143
291
|
autoupdate: false,
|
|
144
292
|
provider: {
|
|
145
293
|
[TESTCHIMP_PROVIDER_ID]: {
|
|
@@ -156,6 +304,23 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
156
304
|
},
|
|
157
305
|
},
|
|
158
306
|
},
|
|
307
|
+
agent: {
|
|
308
|
+
[OPENCODE_AGENT_ID]: {
|
|
309
|
+
mode: "primary",
|
|
310
|
+
description: "TestChimp ChimpHands cloud agent (PR-only repo writes)",
|
|
311
|
+
prompt: CHIMPHANDS_AGENT_PROMPT,
|
|
312
|
+
steps: 80,
|
|
313
|
+
permission: {
|
|
314
|
+
skill: "allow",
|
|
315
|
+
bash: "allow",
|
|
316
|
+
edit: "allow",
|
|
317
|
+
read: "allow",
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
skills: {
|
|
322
|
+
paths: [".agents/skills/testchimp"],
|
|
323
|
+
},
|
|
159
324
|
mcp: {
|
|
160
325
|
testchimp: {
|
|
161
326
|
type: "local",
|
|
@@ -167,125 +332,144 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
167
332
|
}, null, 2));
|
|
168
333
|
return model;
|
|
169
334
|
}
|
|
170
|
-
function
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
335
|
+
function buildOpencodeArgs(prompt, model, opencodeSessionId) {
|
|
336
|
+
const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
|
|
337
|
+
if (opencodeSessionId?.trim()) {
|
|
338
|
+
args.push("--session", opencodeSessionId.trim());
|
|
339
|
+
}
|
|
340
|
+
return args;
|
|
341
|
+
}
|
|
342
|
+
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks) {
|
|
343
|
+
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
344
|
+
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId);
|
|
345
|
+
const child = spawn("opencode", baseArgs, {
|
|
346
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
347
|
+
env: childEnv,
|
|
348
|
+
});
|
|
349
|
+
let err = "";
|
|
350
|
+
child.stderr.on("data", (d) => {
|
|
351
|
+
err += d.toString();
|
|
352
|
+
});
|
|
353
|
+
return new Promise((resolve) => {
|
|
354
|
+
let buf = "";
|
|
355
|
+
let fatalError = null;
|
|
356
|
+
const textByPartId = new Map();
|
|
357
|
+
const noteSessionId = (sessionId) => {
|
|
358
|
+
const id = sessionId?.trim();
|
|
359
|
+
if (!id || id === activeSessionId)
|
|
360
|
+
return;
|
|
361
|
+
activeSessionId = id;
|
|
362
|
+
callbacks.onSessionId?.(id);
|
|
363
|
+
};
|
|
364
|
+
const handleOpencodeLine = (line) => {
|
|
365
|
+
if (!line.trim())
|
|
366
|
+
return;
|
|
367
|
+
const fatal = extractOpencodeFatalError(line);
|
|
368
|
+
if (fatal) {
|
|
369
|
+
fatalError = fatal;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const ev = parseOpencodeEvent(line);
|
|
373
|
+
if (!ev?.type)
|
|
374
|
+
return;
|
|
375
|
+
noteSessionId(ev.sessionID);
|
|
376
|
+
switch (ev.type) {
|
|
377
|
+
case "text": {
|
|
378
|
+
const chunk = ev.part?.text;
|
|
379
|
+
if (!chunk)
|
|
380
|
+
return;
|
|
381
|
+
const partId = ev.part?.id || ev.part?.messageID;
|
|
382
|
+
if (!partId) {
|
|
383
|
+
callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const next = (textByPartId.get(partId) || "") + chunk;
|
|
387
|
+
textByPartId.set(partId, next);
|
|
388
|
+
callbacks.postEvent(ROLE_ASSISTANT, next, {
|
|
389
|
+
throttle: true,
|
|
390
|
+
messageId: opencodeMessageId("oc_text_", ev.part),
|
|
391
|
+
});
|
|
200
392
|
return;
|
|
201
393
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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));
|
|
394
|
+
case "reasoning": {
|
|
395
|
+
const chunk = ev.part?.text;
|
|
396
|
+
if (!chunk)
|
|
218
397
|
return;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return;
|
|
223
|
-
postEvent(ROLE_TOOL, formatToolUseContent(ev.part), undefined, opencodeMessageId("oc_tool_", ev.part));
|
|
398
|
+
const partId = ev.part?.id || ev.part?.messageID;
|
|
399
|
+
if (!partId) {
|
|
400
|
+
callbacks.postEvent(ROLE_REASONING, chunk, { throttle: true });
|
|
224
401
|
return;
|
|
225
402
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
403
|
+
const reasoningKey = `reasoning:${partId}`;
|
|
404
|
+
const next = (textByPartId.get(reasoningKey) || "") + chunk;
|
|
405
|
+
textByPartId.set(reasoningKey, next);
|
|
406
|
+
callbacks.postEvent(ROLE_REASONING, next, {
|
|
407
|
+
throttle: true,
|
|
408
|
+
messageId: opencodeMessageId("oc_reasoning_", ev.part),
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
case "tool_use": {
|
|
413
|
+
const status = ev.part?.state?.status;
|
|
414
|
+
if (!status || status === "pending")
|
|
232
415
|
return;
|
|
416
|
+
const toolContent = formatToolUseContent(ev.part);
|
|
417
|
+
callbacks.postEvent(ROLE_TOOL, toolContent, {
|
|
418
|
+
messageId: opencodeMessageId("oc_tool_", ev.part),
|
|
419
|
+
});
|
|
420
|
+
if (status === "completed") {
|
|
421
|
+
const detected = detectWorkingBranchFromToolOutput(toolContent);
|
|
422
|
+
if (detected.branch) {
|
|
423
|
+
callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
|
|
424
|
+
}
|
|
233
425
|
}
|
|
234
|
-
case "step_start":
|
|
235
|
-
case "step_finish":
|
|
236
|
-
return;
|
|
237
|
-
default:
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
child.stdout.on("data", (chunk) => {
|
|
242
|
-
buf += chunk.toString();
|
|
243
|
-
const lines = buf.split("\n");
|
|
244
|
-
buf = lines.pop() || "";
|
|
245
|
-
for (const line of lines) {
|
|
246
|
-
handleOpencodeLine(line);
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
child.on("close", (code) => {
|
|
250
|
-
if (buf.trim()) {
|
|
251
|
-
handleOpencodeLine(buf.trim());
|
|
252
|
-
}
|
|
253
|
-
const stderrFatal = extractOpencodeFatalError(err);
|
|
254
|
-
if (stderrFatal)
|
|
255
|
-
fatalError = stderrFatal;
|
|
256
|
-
if (fatalError) {
|
|
257
|
-
resolve({ code: 1, err: fatalError });
|
|
258
426
|
return;
|
|
259
427
|
}
|
|
260
|
-
|
|
261
|
-
|
|
428
|
+
case "error": {
|
|
429
|
+
const msg = ev.error?.data?.message ||
|
|
430
|
+
ev.error?.message ||
|
|
431
|
+
line.trim();
|
|
432
|
+
if (msg)
|
|
433
|
+
fatalError = msg;
|
|
262
434
|
return;
|
|
263
435
|
}
|
|
264
|
-
|
|
265
|
-
|
|
436
|
+
case "step_start":
|
|
437
|
+
case "step_finish":
|
|
438
|
+
return;
|
|
439
|
+
default:
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
child.stdout.on("data", (chunk) => {
|
|
444
|
+
buf += chunk.toString();
|
|
445
|
+
const lines = buf.split("\n");
|
|
446
|
+
buf = lines.pop() || "";
|
|
447
|
+
for (const line of lines) {
|
|
448
|
+
handleOpencodeLine(line);
|
|
449
|
+
}
|
|
266
450
|
});
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
451
|
+
child.on("close", (code) => {
|
|
452
|
+
if (buf.trim()) {
|
|
453
|
+
handleOpencodeLine(buf.trim());
|
|
454
|
+
}
|
|
455
|
+
const stderrFatal = extractOpencodeFatalError(err);
|
|
456
|
+
if (stderrFatal)
|
|
457
|
+
fatalError = stderrFatal;
|
|
458
|
+
if (fatalError) {
|
|
459
|
+
resolve({ code: 1, err: fatalError, opencodeSessionId: activeSessionId });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (code != null && code !== 0) {
|
|
463
|
+
resolve({
|
|
464
|
+
code,
|
|
465
|
+
err: summarizeOpencodeFailure(err, buf, code),
|
|
466
|
+
opencodeSessionId: activeSessionId,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
resolve({ code: code == null ? 1 : code, err, opencodeSessionId: activeSessionId });
|
|
274
471
|
});
|
|
275
|
-
|
|
276
|
-
if (fatal)
|
|
277
|
-
return Promise.resolve({ code: 1, err: fatal });
|
|
278
|
-
if (out)
|
|
279
|
-
postEvent(ROLE_ASSISTANT, out);
|
|
280
|
-
return Promise.resolve({ code: 0, err: "" });
|
|
281
|
-
}
|
|
282
|
-
catch (e) {
|
|
283
|
-
const errObj = e;
|
|
284
|
-
const stderr = errObj.stderr?.toString() || "";
|
|
285
|
-
const stdout = errObj.stdout?.toString() || "";
|
|
286
|
-
const fatal = summarizeOpencodeFailure(stderr, stdout, errObj.status ?? 1);
|
|
287
|
-
return Promise.resolve({ code: errObj.status || 1, err: fatal });
|
|
288
|
-
}
|
|
472
|
+
});
|
|
289
473
|
}
|
|
290
474
|
function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
291
475
|
const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
|
|
@@ -368,7 +552,6 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
368
552
|
export async function runChimphands(opts) {
|
|
369
553
|
const apiKey = requireApiKey();
|
|
370
554
|
const backend = getBackendUrl();
|
|
371
|
-
// Ensure child processes see the resolved backend (prod default when unset).
|
|
372
555
|
process.env.TESTCHIMP_BACKEND_URL = backend;
|
|
373
556
|
const sessionId = (opts.sessionId || process.env.SESSION_ID || "").trim();
|
|
374
557
|
if (!sessionId) {
|
|
@@ -380,10 +563,13 @@ export async function runChimphands(opts) {
|
|
|
380
563
|
});
|
|
381
564
|
const boot = JSON.parse(bootText);
|
|
382
565
|
const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
|
|
566
|
+
const poster = new AgentEventPoster(backend, apiKey, sessionId);
|
|
383
567
|
if (githubRunId) {
|
|
384
|
-
|
|
568
|
+
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
385
569
|
sessionId,
|
|
386
570
|
githubRunId,
|
|
571
|
+
}).catch((err) => {
|
|
572
|
+
console.error(`ChimpHands link run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
387
573
|
});
|
|
388
574
|
}
|
|
389
575
|
const userId = boot.chimphands_service_account_user_id || "";
|
|
@@ -393,6 +579,26 @@ export async function runChimphands(opts) {
|
|
|
393
579
|
mkdirSync(".opencode", { recursive: true });
|
|
394
580
|
const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
|
|
395
581
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
582
|
+
let opencodeSessionId = boot.opencode_session_id?.trim() || undefined;
|
|
583
|
+
const conversationSummary = boot.conversation_summary || "";
|
|
584
|
+
let workingBranch = boot.working_branch?.trim() || undefined;
|
|
585
|
+
let pullRequestUrl = boot.pull_request_url?.trim() || undefined;
|
|
586
|
+
const noteWorkingBranch = (branch, prUrl) => {
|
|
587
|
+
const normalizedBranch = branch.trim();
|
|
588
|
+
if (!normalizedBranch)
|
|
589
|
+
return;
|
|
590
|
+
const branchIsNew = !workingBranch;
|
|
591
|
+
const nextPr = prUrl?.trim() || pullRequestUrl;
|
|
592
|
+
const prIsNew = !!prUrl?.trim() && prUrl.trim() !== pullRequestUrl;
|
|
593
|
+
if (workingBranch === normalizedBranch && !prIsNew)
|
|
594
|
+
return;
|
|
595
|
+
workingBranch = normalizedBranch;
|
|
596
|
+
if (prUrl?.trim())
|
|
597
|
+
pullRequestUrl = prUrl.trim();
|
|
598
|
+
if (branchIsNew || prIsNew) {
|
|
599
|
+
poster.reportWorkingBranch(normalizedBranch, nextPr);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
396
602
|
const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
|
|
397
603
|
const queue = [];
|
|
398
604
|
const seenUserMessageIds = new Set();
|
|
@@ -430,17 +636,12 @@ export async function runChimphands(opts) {
|
|
|
430
636
|
// Polling is best-effort when inbound SSE misses an event.
|
|
431
637
|
}
|
|
432
638
|
};
|
|
433
|
-
const postEvent = (role, content,
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (messageId)
|
|
440
|
-
body.messageId = messageId;
|
|
441
|
-
if (status != null)
|
|
442
|
-
body.status = status;
|
|
443
|
-
postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
|
|
639
|
+
const postEvent = (role, content, opts) => {
|
|
640
|
+
const bodyOpts = { ...opts };
|
|
641
|
+
if (opencodeSessionId && !bodyOpts.opencodeSessionId) {
|
|
642
|
+
bodyOpts.opencodeSessionId = opencodeSessionId;
|
|
643
|
+
}
|
|
644
|
+
poster.fireAndForget(role, content, bodyOpts);
|
|
444
645
|
};
|
|
445
646
|
const complete = (status, errorMessage) => {
|
|
446
647
|
const body = { sessionId, status };
|
|
@@ -448,12 +649,15 @@ export async function runChimphands(opts) {
|
|
|
448
649
|
body.errorMessage = String(errorMessage).slice(0, 4000);
|
|
449
650
|
if (githubRunId)
|
|
450
651
|
body.githubRunId = githubRunId;
|
|
451
|
-
|
|
652
|
+
void postJson(backend, apiKey, "/api/chimphands/complete_session", body).catch((err) => {
|
|
653
|
+
console.error(`ChimpHands complete_session failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
654
|
+
});
|
|
452
655
|
};
|
|
453
656
|
const childEnv = {
|
|
454
657
|
...process.env,
|
|
455
658
|
TESTCHIMP_API_KEY: apiKey,
|
|
456
659
|
TESTCHIMP_BACKEND_URL: backend,
|
|
660
|
+
TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
|
|
457
661
|
};
|
|
458
662
|
if (userId)
|
|
459
663
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
@@ -464,11 +668,8 @@ export async function runChimphands(opts) {
|
|
|
464
668
|
},
|
|
465
669
|
shouldRun: () => sessionActive,
|
|
466
670
|
});
|
|
467
|
-
|
|
671
|
+
poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
|
|
468
672
|
let prompt = normalizeUserMessage(promptInput || boot.initial_prompt || "");
|
|
469
|
-
if (boot.conversation_summary) {
|
|
470
|
-
prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
|
|
471
|
-
}
|
|
472
673
|
for (const m of boot.pending_user_messages || []) {
|
|
473
674
|
if (m?.content)
|
|
474
675
|
enqueueUserMessage({ content: m.content });
|
|
@@ -505,17 +706,50 @@ export async function runChimphands(opts) {
|
|
|
505
706
|
tick();
|
|
506
707
|
});
|
|
507
708
|
while (prompt) {
|
|
508
|
-
|
|
709
|
+
let useOpencodeSessionId = opencodeSessionId;
|
|
710
|
+
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
711
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
712
|
+
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
713
|
+
onSessionId: (id) => {
|
|
714
|
+
opencodeSessionId = id;
|
|
715
|
+
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
716
|
+
sessionId,
|
|
717
|
+
opencodeSessionId: id,
|
|
718
|
+
}).catch(() => { });
|
|
719
|
+
},
|
|
720
|
+
onWorkingBranch: noteWorkingBranch,
|
|
721
|
+
postEvent,
|
|
722
|
+
});
|
|
723
|
+
if (result.code !== 0 &&
|
|
724
|
+
useOpencodeSessionId &&
|
|
725
|
+
isMissingOpencodeSessionError(result.err || "")) {
|
|
726
|
+
console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
|
|
727
|
+
opencodeSessionId = undefined;
|
|
728
|
+
isNewOpencodeSession = true;
|
|
729
|
+
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
730
|
+
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
731
|
+
onSessionId: (id) => {
|
|
732
|
+
opencodeSessionId = id;
|
|
733
|
+
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
734
|
+
sessionId,
|
|
735
|
+
opencodeSessionId: id,
|
|
736
|
+
}).catch(() => { });
|
|
737
|
+
},
|
|
738
|
+
onWorkingBranch: noteWorkingBranch,
|
|
739
|
+
postEvent,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
await poster.flush();
|
|
743
|
+
if (result.opencodeSessionId) {
|
|
744
|
+
opencodeSessionId = result.opencodeSessionId;
|
|
745
|
+
}
|
|
509
746
|
if (result.code !== 0) {
|
|
510
747
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
511
748
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
512
749
|
try {
|
|
513
|
-
await
|
|
514
|
-
sessionId,
|
|
515
|
-
role: ROLE_STATUS,
|
|
516
|
-
content: errMsg,
|
|
750
|
+
await poster.enqueue(ROLE_STATUS, errMsg, {
|
|
517
751
|
status: STATUS_FAILED,
|
|
518
|
-
|
|
752
|
+
opencodeSessionId,
|
|
519
753
|
});
|
|
520
754
|
await postJson(backend, apiKey, "/api/chimphands/complete_session", {
|
|
521
755
|
sessionId,
|
|
@@ -527,19 +761,19 @@ export async function runChimphands(opts) {
|
|
|
527
761
|
catch (reportErr) {
|
|
528
762
|
const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
|
|
529
763
|
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
530
|
-
postEvent(ROLE_STATUS, errMsg, STATUS_FAILED);
|
|
764
|
+
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
531
765
|
complete(STATUS_FAILED, errMsg);
|
|
532
766
|
}
|
|
533
767
|
process.exit(result.code || 1);
|
|
534
768
|
}
|
|
535
|
-
postEvent(ROLE_STATUS, "Waiting for user input", STATUS_WAITING_USER);
|
|
536
|
-
// Idle countdown starts when the agent finishes a turn, not at job bootstrap.
|
|
769
|
+
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
537
770
|
lastUserActivity = Date.now();
|
|
538
771
|
idle = false;
|
|
539
772
|
prompt = (await waitForNextPrompt()) || "";
|
|
540
773
|
}
|
|
541
774
|
sessionActive = false;
|
|
542
775
|
stopInbound();
|
|
776
|
+
await poster.flush();
|
|
543
777
|
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
544
778
|
complete(STATUS_IDLE);
|
|
545
779
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -1562,6 +1562,28 @@ export function buildCliProgram() {
|
|
|
1562
1562
|
console.log(await runTool("list-api-operation-interactions", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1563
1563
|
});
|
|
1564
1564
|
const chimphands = program.command("chimphands").description("ChimpHands GitHub Actions agent bridge");
|
|
1565
|
+
chimphands
|
|
1566
|
+
.command("report-branch")
|
|
1567
|
+
.description("Report the conversation working branch (and optional PR URL) to TestChimp")
|
|
1568
|
+
.requiredOption("--branch <name>", "Feature branch name (testchimp-* or chimphands-*)")
|
|
1569
|
+
.option("--pr-url <url>", "Open pull request URL")
|
|
1570
|
+
.option("--session-id <id>", "ChimpHands session id (or SESSION_ID env)")
|
|
1571
|
+
.action(async (opts) => {
|
|
1572
|
+
const { reportWorkingBranch } = await import("../chimphands/run.js");
|
|
1573
|
+
try {
|
|
1574
|
+
await reportWorkingBranch({
|
|
1575
|
+
sessionId: String(opts.sessionId || process.env.SESSION_ID || "").trim(),
|
|
1576
|
+
branch: String(opts.branch || "").trim(),
|
|
1577
|
+
pullRequestUrl: opts.prUrl != null ? String(opts.prUrl).trim() : undefined,
|
|
1578
|
+
});
|
|
1579
|
+
console.log(JSON.stringify({ ok: true }));
|
|
1580
|
+
}
|
|
1581
|
+
catch (e) {
|
|
1582
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1583
|
+
console.error(`[testchimp chimphands report-branch] ${msg}`);
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1565
1587
|
chimphands
|
|
1566
1588
|
.command("run")
|
|
1567
1589
|
.description("Bootstrap session, configure OpenCode, and run the interactive bridge")
|
package/package.json
CHANGED