@testchimp/cli 0.1.40 → 0.1.42
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 +406 -169
- 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";
|
|
@@ -17,9 +17,62 @@ const STATUS_RUNNING = "CHIMPHANDS_SESSION_STATUS_RUNNING";
|
|
|
17
17
|
const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
|
|
18
18
|
const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
|
|
19
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.`;
|
|
20
42
|
function normalizeUserMessage(content) {
|
|
21
43
|
return content.trim();
|
|
22
44
|
}
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
/** Protobuf JsonFormat uses camelCase; accept snake_case too for resilience. */
|
|
49
|
+
function bootStr(boot, snake, camel) {
|
|
50
|
+
const raw = boot;
|
|
51
|
+
const v = raw[snake] ?? raw[camel];
|
|
52
|
+
return typeof v === "string" ? v.trim() : "";
|
|
53
|
+
}
|
|
54
|
+
function bootNum(boot, snake, camel) {
|
|
55
|
+
const raw = boot;
|
|
56
|
+
const v = raw[snake] ?? raw[camel];
|
|
57
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Agent/CLI hook: persist the conversation working branch (+ optional PR) for UI + later turns. */
|
|
60
|
+
export async function reportWorkingBranch(opts) {
|
|
61
|
+
const apiKey = requireApiKey();
|
|
62
|
+
const backend = getBackendUrl();
|
|
63
|
+
const branch = opts.branch.trim();
|
|
64
|
+
if (!branch) {
|
|
65
|
+
throw new Error("branch is required");
|
|
66
|
+
}
|
|
67
|
+
const body = {
|
|
68
|
+
sessionId: opts.sessionId.trim(),
|
|
69
|
+
workingBranch: branch,
|
|
70
|
+
};
|
|
71
|
+
const pr = opts.pullRequestUrl?.trim();
|
|
72
|
+
if (pr)
|
|
73
|
+
body.pullRequestUrl = pr;
|
|
74
|
+
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", body);
|
|
75
|
+
}
|
|
23
76
|
function apiHeaders(apiKey) {
|
|
24
77
|
return {
|
|
25
78
|
"Content-Type": "application/json",
|
|
@@ -38,16 +91,75 @@ async function postJson(backend, apiKey, path, body) {
|
|
|
38
91
|
}
|
|
39
92
|
return text;
|
|
40
93
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
94
|
+
/** Serializes post_agent_event calls so streaming chunks commit and fan out in order. */
|
|
95
|
+
class AgentEventPoster {
|
|
96
|
+
backend;
|
|
97
|
+
apiKey;
|
|
98
|
+
sessionId;
|
|
99
|
+
chain = Promise.resolve();
|
|
100
|
+
lastStreamPostAt = 0;
|
|
101
|
+
constructor(backend, apiKey, sessionId) {
|
|
102
|
+
this.backend = backend;
|
|
103
|
+
this.apiKey = apiKey;
|
|
104
|
+
this.sessionId = sessionId;
|
|
105
|
+
}
|
|
106
|
+
enqueue(role, content, opts) {
|
|
107
|
+
const body = {
|
|
108
|
+
sessionId: this.sessionId,
|
|
109
|
+
role,
|
|
110
|
+
content: String(content || "").slice(0, 20000),
|
|
111
|
+
};
|
|
112
|
+
if (opts?.messageId)
|
|
113
|
+
body.messageId = opts.messageId;
|
|
114
|
+
if (opts?.status != null)
|
|
115
|
+
body.status = opts.status;
|
|
116
|
+
if (opts?.opencodeSessionId)
|
|
117
|
+
body.opencodeSessionId = opts.opencodeSessionId;
|
|
118
|
+
if (opts?.workingBranch)
|
|
119
|
+
body.workingBranch = opts.workingBranch;
|
|
120
|
+
if (opts?.pullRequestUrl)
|
|
121
|
+
body.pullRequestUrl = opts.pullRequestUrl;
|
|
122
|
+
this.chain = this.chain.then(async () => {
|
|
123
|
+
if (opts?.throttle) {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
|
|
126
|
+
if (wait > 0)
|
|
127
|
+
await sleep(wait);
|
|
128
|
+
this.lastStreamPostAt = Date.now();
|
|
129
|
+
}
|
|
130
|
+
await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
|
|
131
|
+
});
|
|
132
|
+
return this.chain;
|
|
133
|
+
}
|
|
134
|
+
fireAndForget(role, content, opts) {
|
|
135
|
+
void this.enqueue(role, content, opts).catch((err) => {
|
|
136
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
137
|
+
console.error(`ChimpHands API telemetry failed: ${detail}`);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
flush() {
|
|
141
|
+
return this.chain;
|
|
142
|
+
}
|
|
143
|
+
reportWorkingBranch(branch, pullRequestUrl) {
|
|
144
|
+
this.chain = this.chain.then(async () => {
|
|
145
|
+
const body = {
|
|
146
|
+
sessionId: this.sessionId,
|
|
147
|
+
workingBranch: branch,
|
|
148
|
+
};
|
|
149
|
+
if (pullRequestUrl?.trim())
|
|
150
|
+
body.pullRequestUrl = pullRequestUrl.trim();
|
|
151
|
+
await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
|
|
152
|
+
});
|
|
153
|
+
void this.chain.catch((err) => {
|
|
154
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
155
|
+
console.error(`ChimpHands report-branch failed: ${detail}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
46
158
|
}
|
|
47
159
|
const TESTCHIMP_PROVIDER_ID = "testchimp";
|
|
48
160
|
function resolveOpencodeModelId(boot) {
|
|
49
|
-
const raw = (boot
|
|
50
|
-
const modelId = raw.includes("/") ? raw.split("/").pop() || "gpt-
|
|
161
|
+
const raw = bootStr(boot, "llm_model", "llmModel") || "gpt-5.6-luna";
|
|
162
|
+
const modelId = raw.includes("/") ? raw.split("/").pop() || "gpt-5.6-luna" : raw;
|
|
51
163
|
return modelId;
|
|
52
164
|
}
|
|
53
165
|
function resolveOpencodeModel(boot) {
|
|
@@ -94,10 +206,18 @@ function parseOpencodeEvent(line) {
|
|
|
94
206
|
}
|
|
95
207
|
function formatToolUseContent(part) {
|
|
96
208
|
const title = part.state?.title || part.tool || "tool";
|
|
209
|
+
const status = part.state?.status?.trim();
|
|
210
|
+
const input = part.state?.input;
|
|
211
|
+
const inputText = input && Object.keys(input).length
|
|
212
|
+
? `\nInput: ${JSON.stringify(input).slice(0, 4000)}`
|
|
213
|
+
: "";
|
|
97
214
|
const output = part.state?.output?.trim();
|
|
215
|
+
const statusLine = status ? `[${title}] (${status})` : `[${title}]`;
|
|
98
216
|
if (output)
|
|
99
|
-
return
|
|
100
|
-
|
|
217
|
+
return `${statusLine}\n${output}`;
|
|
218
|
+
if (inputText)
|
|
219
|
+
return `${statusLine}${inputText}`;
|
|
220
|
+
return statusLine;
|
|
101
221
|
}
|
|
102
222
|
function opencodeMessageId(prefix, part) {
|
|
103
223
|
const raw = part?.id || part?.messageID;
|
|
@@ -125,31 +245,76 @@ function summarizeOpencodeFailure(stderr, stdout, exitCode) {
|
|
|
125
245
|
}
|
|
126
246
|
return exitCode ? `opencode exited with code ${exitCode}` : "opencode failed";
|
|
127
247
|
}
|
|
248
|
+
function isMissingOpencodeSessionError(message) {
|
|
249
|
+
const m = message.toLowerCase();
|
|
250
|
+
return ((m.includes("session") && m.includes("not found")) ||
|
|
251
|
+
m.includes("unknown session") ||
|
|
252
|
+
m.includes("invalid session"));
|
|
253
|
+
}
|
|
254
|
+
function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
|
|
255
|
+
const parts = [];
|
|
256
|
+
if (workingBranch?.trim()) {
|
|
257
|
+
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.", "");
|
|
258
|
+
}
|
|
259
|
+
const task = normalizeUserMessage(userPrompt);
|
|
260
|
+
if (isNewOpencodeSession && conversationSummary.trim()) {
|
|
261
|
+
parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
|
|
262
|
+
return parts.filter(Boolean).join("\n");
|
|
263
|
+
}
|
|
264
|
+
if (parts.length) {
|
|
265
|
+
parts.push(`Current task:\n${task}`);
|
|
266
|
+
return parts.filter(Boolean).join("\n");
|
|
267
|
+
}
|
|
268
|
+
return task;
|
|
269
|
+
}
|
|
270
|
+
function detectWorkingBranchFromToolOutput(output) {
|
|
271
|
+
const text = output.trim();
|
|
272
|
+
if (!text)
|
|
273
|
+
return {};
|
|
274
|
+
const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
|
|
275
|
+
const checkoutMatch = text.match(/checkout\s+-b\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
276
|
+
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
277
|
+
const branchMatch = text.match(/branch['":\s]+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
278
|
+
const branch = (checkoutMatch?.[1] || pushMatch?.[1] || branchMatch?.[1])?.replace(/[`'"]/g, "");
|
|
279
|
+
return {
|
|
280
|
+
branch,
|
|
281
|
+
pullRequestUrl: prMatch?.[0],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
128
284
|
function writeOpencodeConfig(backend, apiKey, boot) {
|
|
129
|
-
const llmBase = (boot
|
|
130
|
-
const llmKey = apiKey || boot
|
|
285
|
+
const llmBase = (bootStr(boot, "llm_base_url", "llmBaseUrl") || `${backend}/v1`).replace(/\/$/, "");
|
|
286
|
+
const llmKey = apiKey || bootStr(boot, "llm_api_key", "llmApiKey");
|
|
131
287
|
const modelId = resolveOpencodeModelId(boot);
|
|
132
288
|
const model = `${TESTCHIMP_PROVIDER_ID}/${modelId}`;
|
|
289
|
+
const sessionId = bootStr(boot, "session_id", "sessionId");
|
|
133
290
|
const mcpEnv = {
|
|
134
291
|
TESTCHIMP_API_KEY: apiKey,
|
|
135
292
|
TESTCHIMP_BACKEND_URL: backend,
|
|
293
|
+
TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
|
|
136
294
|
};
|
|
137
|
-
const serviceUserId = boot
|
|
295
|
+
const serviceUserId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
|
|
138
296
|
if (serviceUserId) {
|
|
139
297
|
mcpEnv.TESTCHIMP_USER_ID = serviceUserId;
|
|
140
298
|
}
|
|
299
|
+
const providerOptions = {
|
|
300
|
+
apiKey: llmKey,
|
|
301
|
+
baseURL: llmBase,
|
|
302
|
+
};
|
|
303
|
+
if (sessionId) {
|
|
304
|
+
providerOptions.headers = {
|
|
305
|
+
"X-TestChimp-ChimpHands-Session-Id": sessionId,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
141
308
|
writeFileSync("opencode.json", JSON.stringify({
|
|
142
309
|
$schema: "https://opencode.ai/config.json",
|
|
143
310
|
model,
|
|
311
|
+
default_agent: OPENCODE_AGENT_ID,
|
|
144
312
|
autoupdate: false,
|
|
145
313
|
provider: {
|
|
146
314
|
[TESTCHIMP_PROVIDER_ID]: {
|
|
147
315
|
npm: "@ai-sdk/openai-compatible",
|
|
148
316
|
name: "TestChimp",
|
|
149
|
-
options:
|
|
150
|
-
apiKey: llmKey,
|
|
151
|
-
baseURL: llmBase,
|
|
152
|
-
},
|
|
317
|
+
options: providerOptions,
|
|
153
318
|
models: {
|
|
154
319
|
[modelId]: {
|
|
155
320
|
name: modelId,
|
|
@@ -157,6 +322,23 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
157
322
|
},
|
|
158
323
|
},
|
|
159
324
|
},
|
|
325
|
+
agent: {
|
|
326
|
+
[OPENCODE_AGENT_ID]: {
|
|
327
|
+
mode: "primary",
|
|
328
|
+
description: "TestChimp ChimpHands cloud agent (PR-only repo writes)",
|
|
329
|
+
prompt: CHIMPHANDS_AGENT_PROMPT,
|
|
330
|
+
steps: 80,
|
|
331
|
+
permission: {
|
|
332
|
+
skill: "allow",
|
|
333
|
+
bash: "allow",
|
|
334
|
+
edit: "allow",
|
|
335
|
+
read: "allow",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
skills: {
|
|
340
|
+
paths: [".agents/skills/testchimp"],
|
|
341
|
+
},
|
|
160
342
|
mcp: {
|
|
161
343
|
testchimp: {
|
|
162
344
|
type: "local",
|
|
@@ -168,140 +350,144 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
168
350
|
}, null, 2));
|
|
169
351
|
return model;
|
|
170
352
|
}
|
|
171
|
-
function
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const next = (textByPartId.get(partId) || "") + chunk;
|
|
217
|
-
textByPartId.set(partId, next);
|
|
218
|
-
postEvent(ROLE_ASSISTANT, next, undefined, opencodeMessageId("oc_text_", ev.part));
|
|
353
|
+
function buildOpencodeArgs(prompt, model, opencodeSessionId) {
|
|
354
|
+
const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
|
|
355
|
+
if (opencodeSessionId?.trim()) {
|
|
356
|
+
args.push("--session", opencodeSessionId.trim());
|
|
357
|
+
}
|
|
358
|
+
return args;
|
|
359
|
+
}
|
|
360
|
+
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks) {
|
|
361
|
+
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
362
|
+
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId);
|
|
363
|
+
const child = spawn("opencode", baseArgs, {
|
|
364
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
365
|
+
env: childEnv,
|
|
366
|
+
});
|
|
367
|
+
let err = "";
|
|
368
|
+
child.stderr.on("data", (d) => {
|
|
369
|
+
err += d.toString();
|
|
370
|
+
});
|
|
371
|
+
return new Promise((resolve) => {
|
|
372
|
+
let buf = "";
|
|
373
|
+
let fatalError = null;
|
|
374
|
+
const textByPartId = new Map();
|
|
375
|
+
const noteSessionId = (sessionId) => {
|
|
376
|
+
const id = sessionId?.trim();
|
|
377
|
+
if (!id || id === activeSessionId)
|
|
378
|
+
return;
|
|
379
|
+
activeSessionId = id;
|
|
380
|
+
callbacks.onSessionId?.(id);
|
|
381
|
+
};
|
|
382
|
+
const handleOpencodeLine = (line) => {
|
|
383
|
+
if (!line.trim())
|
|
384
|
+
return;
|
|
385
|
+
const fatal = extractOpencodeFatalError(line);
|
|
386
|
+
if (fatal) {
|
|
387
|
+
fatalError = fatal;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const ev = parseOpencodeEvent(line);
|
|
391
|
+
if (!ev?.type)
|
|
392
|
+
return;
|
|
393
|
+
noteSessionId(ev.sessionID);
|
|
394
|
+
switch (ev.type) {
|
|
395
|
+
case "text": {
|
|
396
|
+
const chunk = ev.part?.text;
|
|
397
|
+
if (!chunk)
|
|
219
398
|
return;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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));
|
|
399
|
+
const partId = ev.part?.id || ev.part?.messageID;
|
|
400
|
+
if (!partId) {
|
|
401
|
+
callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
|
|
234
402
|
return;
|
|
235
403
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
404
|
+
const next = (textByPartId.get(partId) || "") + chunk;
|
|
405
|
+
textByPartId.set(partId, next);
|
|
406
|
+
callbacks.postEvent(ROLE_ASSISTANT, next, {
|
|
407
|
+
throttle: true,
|
|
408
|
+
messageId: opencodeMessageId("oc_text_", ev.part),
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
case "reasoning": {
|
|
413
|
+
const chunk = ev.part?.text;
|
|
414
|
+
if (!chunk)
|
|
240
415
|
return;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
ev.error?.message ||
|
|
245
|
-
line.trim();
|
|
246
|
-
if (msg)
|
|
247
|
-
fatalError = msg;
|
|
416
|
+
const partId = ev.part?.id || ev.part?.messageID;
|
|
417
|
+
if (!partId) {
|
|
418
|
+
callbacks.postEvent(ROLE_REASONING, chunk, { throttle: true });
|
|
248
419
|
return;
|
|
249
420
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
buf += chunk.toString();
|
|
259
|
-
const lines = buf.split("\n");
|
|
260
|
-
buf = lines.pop() || "";
|
|
261
|
-
for (const line of lines) {
|
|
262
|
-
handleOpencodeLine(line);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
child.on("close", (code) => {
|
|
266
|
-
if (buf.trim()) {
|
|
267
|
-
handleOpencodeLine(buf.trim());
|
|
421
|
+
const reasoningKey = `reasoning:${partId}`;
|
|
422
|
+
const next = (textByPartId.get(reasoningKey) || "") + chunk;
|
|
423
|
+
textByPartId.set(reasoningKey, next);
|
|
424
|
+
callbacks.postEvent(ROLE_REASONING, next, {
|
|
425
|
+
throttle: true,
|
|
426
|
+
messageId: opencodeMessageId("oc_reasoning_", ev.part),
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
268
429
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
430
|
+
case "tool_use": {
|
|
431
|
+
const status = ev.part?.state?.status;
|
|
432
|
+
if (!status || status === "pending")
|
|
433
|
+
return;
|
|
434
|
+
const toolContent = formatToolUseContent(ev.part);
|
|
435
|
+
callbacks.postEvent(ROLE_TOOL, toolContent, {
|
|
436
|
+
messageId: opencodeMessageId("oc_tool_", ev.part),
|
|
437
|
+
});
|
|
438
|
+
if (status === "completed") {
|
|
439
|
+
const detected = detectWorkingBranchFromToolOutput(toolContent);
|
|
440
|
+
if (detected.branch) {
|
|
441
|
+
callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
274
444
|
return;
|
|
275
445
|
}
|
|
276
|
-
|
|
277
|
-
|
|
446
|
+
case "error": {
|
|
447
|
+
const msg = ev.error?.data?.message ||
|
|
448
|
+
ev.error?.message ||
|
|
449
|
+
line.trim();
|
|
450
|
+
if (msg)
|
|
451
|
+
fatalError = msg;
|
|
278
452
|
return;
|
|
279
453
|
}
|
|
280
|
-
|
|
281
|
-
|
|
454
|
+
case "step_start":
|
|
455
|
+
case "step_finish":
|
|
456
|
+
return;
|
|
457
|
+
default:
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
child.stdout.on("data", (chunk) => {
|
|
462
|
+
buf += chunk.toString();
|
|
463
|
+
const lines = buf.split("\n");
|
|
464
|
+
buf = lines.pop() || "";
|
|
465
|
+
for (const line of lines) {
|
|
466
|
+
handleOpencodeLine(line);
|
|
467
|
+
}
|
|
282
468
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
469
|
+
child.on("close", (code) => {
|
|
470
|
+
if (buf.trim()) {
|
|
471
|
+
handleOpencodeLine(buf.trim());
|
|
472
|
+
}
|
|
473
|
+
const stderrFatal = extractOpencodeFatalError(err);
|
|
474
|
+
if (stderrFatal)
|
|
475
|
+
fatalError = stderrFatal;
|
|
476
|
+
if (fatalError) {
|
|
477
|
+
resolve({ code: 1, err: fatalError, opencodeSessionId: activeSessionId });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (code != null && code !== 0) {
|
|
481
|
+
resolve({
|
|
482
|
+
code,
|
|
483
|
+
err: summarizeOpencodeFailure(err, buf, code),
|
|
484
|
+
opencodeSessionId: activeSessionId,
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
resolve({ code: code == null ? 1 : code, err, opencodeSessionId: activeSessionId });
|
|
290
489
|
});
|
|
291
|
-
|
|
292
|
-
if (fatal)
|
|
293
|
-
return Promise.resolve({ code: 1, err: fatal });
|
|
294
|
-
if (out)
|
|
295
|
-
postEvent(ROLE_ASSISTANT, out);
|
|
296
|
-
return Promise.resolve({ code: 0, err: "" });
|
|
297
|
-
}
|
|
298
|
-
catch (e) {
|
|
299
|
-
const errObj = e;
|
|
300
|
-
const stderr = errObj.stderr?.toString() || "";
|
|
301
|
-
const stdout = errObj.stdout?.toString() || "";
|
|
302
|
-
const fatal = summarizeOpencodeFailure(stderr, stdout, errObj.status ?? 1);
|
|
303
|
-
return Promise.resolve({ code: errObj.status || 1, err: fatal });
|
|
304
|
-
}
|
|
490
|
+
});
|
|
305
491
|
}
|
|
306
492
|
function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
307
493
|
const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
|
|
@@ -384,7 +570,6 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
384
570
|
export async function runChimphands(opts) {
|
|
385
571
|
const apiKey = requireApiKey();
|
|
386
572
|
const backend = getBackendUrl();
|
|
387
|
-
// Ensure child processes see the resolved backend (prod default when unset).
|
|
388
573
|
process.env.TESTCHIMP_BACKEND_URL = backend;
|
|
389
574
|
const sessionId = (opts.sessionId || process.env.SESSION_ID || "").trim();
|
|
390
575
|
if (!sessionId) {
|
|
@@ -396,20 +581,43 @@ export async function runChimphands(opts) {
|
|
|
396
581
|
});
|
|
397
582
|
const boot = JSON.parse(bootText);
|
|
398
583
|
const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
|
|
584
|
+
const poster = new AgentEventPoster(backend, apiKey, sessionId);
|
|
399
585
|
if (githubRunId) {
|
|
400
|
-
|
|
586
|
+
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
401
587
|
sessionId,
|
|
402
588
|
githubRunId,
|
|
589
|
+
}).catch((err) => {
|
|
590
|
+
console.error(`ChimpHands link run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
403
591
|
});
|
|
404
592
|
}
|
|
405
|
-
const userId = boot
|
|
593
|
+
const userId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
|
|
406
594
|
if (userId) {
|
|
407
595
|
process.env.TESTCHIMP_USER_ID = userId;
|
|
408
596
|
}
|
|
409
597
|
mkdirSync(".opencode", { recursive: true });
|
|
410
598
|
const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
|
|
411
599
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
412
|
-
|
|
600
|
+
let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
|
|
601
|
+
const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
|
|
602
|
+
let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
|
|
603
|
+
let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
|
|
604
|
+
const noteWorkingBranch = (branch, prUrl) => {
|
|
605
|
+
const normalizedBranch = branch.trim();
|
|
606
|
+
if (!normalizedBranch)
|
|
607
|
+
return;
|
|
608
|
+
const branchIsNew = !workingBranch;
|
|
609
|
+
const nextPr = prUrl?.trim() || pullRequestUrl;
|
|
610
|
+
const prIsNew = !!prUrl?.trim() && prUrl.trim() !== pullRequestUrl;
|
|
611
|
+
if (workingBranch === normalizedBranch && !prIsNew)
|
|
612
|
+
return;
|
|
613
|
+
workingBranch = normalizedBranch;
|
|
614
|
+
if (prUrl?.trim())
|
|
615
|
+
pullRequestUrl = prUrl.trim();
|
|
616
|
+
if (branchIsNew || prIsNew) {
|
|
617
|
+
poster.reportWorkingBranch(normalizedBranch, nextPr);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
|
|
413
621
|
const queue = [];
|
|
414
622
|
const seenUserMessageIds = new Set();
|
|
415
623
|
let idle = false;
|
|
@@ -446,17 +654,12 @@ export async function runChimphands(opts) {
|
|
|
446
654
|
// Polling is best-effort when inbound SSE misses an event.
|
|
447
655
|
}
|
|
448
656
|
};
|
|
449
|
-
const postEvent = (role, content,
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (messageId)
|
|
456
|
-
body.messageId = messageId;
|
|
457
|
-
if (status != null)
|
|
458
|
-
body.status = status;
|
|
459
|
-
postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
|
|
657
|
+
const postEvent = (role, content, opts) => {
|
|
658
|
+
const bodyOpts = { ...opts };
|
|
659
|
+
if (opencodeSessionId && !bodyOpts.opencodeSessionId) {
|
|
660
|
+
bodyOpts.opencodeSessionId = opencodeSessionId;
|
|
661
|
+
}
|
|
662
|
+
poster.fireAndForget(role, content, bodyOpts);
|
|
460
663
|
};
|
|
461
664
|
const complete = (status, errorMessage) => {
|
|
462
665
|
const body = { sessionId, status };
|
|
@@ -464,12 +667,15 @@ export async function runChimphands(opts) {
|
|
|
464
667
|
body.errorMessage = String(errorMessage).slice(0, 4000);
|
|
465
668
|
if (githubRunId)
|
|
466
669
|
body.githubRunId = githubRunId;
|
|
467
|
-
|
|
670
|
+
void postJson(backend, apiKey, "/api/chimphands/complete_session", body).catch((err) => {
|
|
671
|
+
console.error(`ChimpHands complete_session failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
672
|
+
});
|
|
468
673
|
};
|
|
469
674
|
const childEnv = {
|
|
470
675
|
...process.env,
|
|
471
676
|
TESTCHIMP_API_KEY: apiKey,
|
|
472
677
|
TESTCHIMP_BACKEND_URL: backend,
|
|
678
|
+
TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
|
|
473
679
|
};
|
|
474
680
|
if (userId)
|
|
475
681
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
@@ -480,12 +686,10 @@ export async function runChimphands(opts) {
|
|
|
480
686
|
},
|
|
481
687
|
shouldRun: () => sessionActive,
|
|
482
688
|
});
|
|
483
|
-
|
|
484
|
-
let prompt = normalizeUserMessage(promptInput || boot
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
for (const m of boot.pending_user_messages || []) {
|
|
689
|
+
poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
|
|
690
|
+
let prompt = normalizeUserMessage(promptInput || bootStr(boot, "initial_prompt", "initialPrompt"));
|
|
691
|
+
const pending = boot.pending_user_messages || boot.pendingUserMessages || [];
|
|
692
|
+
for (const m of pending) {
|
|
489
693
|
if (m?.content)
|
|
490
694
|
enqueueUserMessage({ content: m.content });
|
|
491
695
|
}
|
|
@@ -521,17 +725,50 @@ export async function runChimphands(opts) {
|
|
|
521
725
|
tick();
|
|
522
726
|
});
|
|
523
727
|
while (prompt) {
|
|
524
|
-
|
|
728
|
+
let useOpencodeSessionId = opencodeSessionId;
|
|
729
|
+
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
730
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
731
|
+
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
732
|
+
onSessionId: (id) => {
|
|
733
|
+
opencodeSessionId = id;
|
|
734
|
+
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
735
|
+
sessionId,
|
|
736
|
+
opencodeSessionId: id,
|
|
737
|
+
}).catch(() => { });
|
|
738
|
+
},
|
|
739
|
+
onWorkingBranch: noteWorkingBranch,
|
|
740
|
+
postEvent,
|
|
741
|
+
});
|
|
742
|
+
if (result.code !== 0 &&
|
|
743
|
+
useOpencodeSessionId &&
|
|
744
|
+
isMissingOpencodeSessionError(result.err || "")) {
|
|
745
|
+
console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
|
|
746
|
+
opencodeSessionId = undefined;
|
|
747
|
+
isNewOpencodeSession = true;
|
|
748
|
+
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
749
|
+
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
750
|
+
onSessionId: (id) => {
|
|
751
|
+
opencodeSessionId = id;
|
|
752
|
+
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
753
|
+
sessionId,
|
|
754
|
+
opencodeSessionId: id,
|
|
755
|
+
}).catch(() => { });
|
|
756
|
+
},
|
|
757
|
+
onWorkingBranch: noteWorkingBranch,
|
|
758
|
+
postEvent,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
await poster.flush();
|
|
762
|
+
if (result.opencodeSessionId) {
|
|
763
|
+
opencodeSessionId = result.opencodeSessionId;
|
|
764
|
+
}
|
|
525
765
|
if (result.code !== 0) {
|
|
526
766
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
527
767
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
528
768
|
try {
|
|
529
|
-
await
|
|
530
|
-
sessionId,
|
|
531
|
-
role: ROLE_STATUS,
|
|
532
|
-
content: errMsg,
|
|
769
|
+
await poster.enqueue(ROLE_STATUS, errMsg, {
|
|
533
770
|
status: STATUS_FAILED,
|
|
534
|
-
|
|
771
|
+
opencodeSessionId,
|
|
535
772
|
});
|
|
536
773
|
await postJson(backend, apiKey, "/api/chimphands/complete_session", {
|
|
537
774
|
sessionId,
|
|
@@ -543,19 +780,19 @@ export async function runChimphands(opts) {
|
|
|
543
780
|
catch (reportErr) {
|
|
544
781
|
const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
|
|
545
782
|
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
546
|
-
postEvent(ROLE_STATUS, errMsg, STATUS_FAILED);
|
|
783
|
+
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
547
784
|
complete(STATUS_FAILED, errMsg);
|
|
548
785
|
}
|
|
549
786
|
process.exit(result.code || 1);
|
|
550
787
|
}
|
|
551
|
-
postEvent(ROLE_STATUS, "Waiting for user input", STATUS_WAITING_USER);
|
|
552
|
-
// Idle countdown starts when the agent finishes a turn, not at job bootstrap.
|
|
788
|
+
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
553
789
|
lastUserActivity = Date.now();
|
|
554
790
|
idle = false;
|
|
555
791
|
prompt = (await waitForNextPrompt()) || "";
|
|
556
792
|
}
|
|
557
793
|
sessionActive = false;
|
|
558
794
|
stopInbound();
|
|
795
|
+
await poster.flush();
|
|
559
796
|
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
560
797
|
complete(STATUS_IDLE);
|
|
561
798
|
}
|
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