@testchimp/cli 0.1.65 → 0.1.67
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/llmRetryProxy.d.ts +24 -0
- package/dist/chimphands/llmRetryProxy.js +177 -0
- package/dist/chimphands/llmRetryProxy.test.d.ts +1 -0
- package/dist/chimphands/llmRetryProxy.test.js +156 -0
- package/dist/chimphands/run.js +123 -28
- package/dist/cli/program.js +42 -0
- package/dist/core/schemas.d.ts +10 -0
- package/dist/core/schemas.js +10 -0
- package/dist/core/tools.js +37 -0
- package/package.json +2 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type LlmRetryConfig = {
|
|
2
|
+
delayMs: number;
|
|
3
|
+
maxMs: number;
|
|
4
|
+
};
|
|
5
|
+
export type LlmRetryDeps = {
|
|
6
|
+
delayMs?: number;
|
|
7
|
+
maxMs?: number;
|
|
8
|
+
sleep?: (ms: number) => Promise<void>;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
fetchFn?: typeof fetch;
|
|
11
|
+
log?: (msg: string) => void;
|
|
12
|
+
};
|
|
13
|
+
export declare function resolveLlmRetryConfig(): LlmRetryConfig;
|
|
14
|
+
export declare function isChimphandsLlmRetryEnabled(): boolean;
|
|
15
|
+
export declare function isRetryableLlmFailure(status: number): boolean;
|
|
16
|
+
export declare function isRetryableLlmNetworkError(err: unknown): boolean;
|
|
17
|
+
/** Retry upstream fetch until success, non-retryable status, or deadline. */
|
|
18
|
+
export declare function fetchUpstreamWithRetry(url: string, init: RequestInit, deps?: LlmRetryDeps): Promise<Response>;
|
|
19
|
+
export type LlmRetryProxyHandle = {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
stop: () => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
/** Start a localhost proxy that forwards to FeatureService /v1 with retry. */
|
|
24
|
+
export declare function startLlmRetryProxy(upstreamLlmBase: string, deps?: LlmRetryDeps): Promise<LlmRetryProxyHandle>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localhost LLM retry proxy for ChimpHands: forwards OpenCode /v1 calls to FeatureService
|
|
3
|
+
* and retries transient 502/503/504 + network failures (30s delay, up to 5 min).
|
|
4
|
+
*/
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import { URL } from "node:url";
|
|
7
|
+
const DEFAULT_DELAY_MS = 30_000;
|
|
8
|
+
const DEFAULT_MAX_MS = 300_000;
|
|
9
|
+
function defaultSleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
export function resolveLlmRetryConfig() {
|
|
13
|
+
const delayRaw = Number(process.env.CHIMPHANDS_LLM_RETRY_DELAY_MS ?? DEFAULT_DELAY_MS);
|
|
14
|
+
const maxRaw = Number(process.env.CHIMPHANDS_LLM_RETRY_MAX_MS ?? DEFAULT_MAX_MS);
|
|
15
|
+
return {
|
|
16
|
+
delayMs: Number.isFinite(delayRaw) && delayRaw > 0 ? delayRaw : DEFAULT_DELAY_MS,
|
|
17
|
+
maxMs: Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DEFAULT_MAX_MS,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function isChimphandsLlmRetryEnabled() {
|
|
21
|
+
const v = (process.env.CHIMPHANDS_LLM_RETRY ?? "1").trim().toLowerCase();
|
|
22
|
+
return v !== "0" && v !== "false" && v !== "off" && v !== "no";
|
|
23
|
+
}
|
|
24
|
+
export function isRetryableLlmFailure(status) {
|
|
25
|
+
return status === 502 || status === 503 || status === 504;
|
|
26
|
+
}
|
|
27
|
+
export function isRetryableLlmNetworkError(err) {
|
|
28
|
+
if (!(err instanceof Error)) {
|
|
29
|
+
return /fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|socket hang up|EAI_AGAIN/i.test(String(err));
|
|
30
|
+
}
|
|
31
|
+
const code = err.code;
|
|
32
|
+
if (code && ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "ECONNRESET", "EAI_AGAIN"].includes(code)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return /fetch failed|socket hang up|network|timed out|timeout/i.test(err.message);
|
|
36
|
+
}
|
|
37
|
+
function formatRemainingMs(ms) {
|
|
38
|
+
return `${Math.max(0, Math.ceil(ms / 1000))}s`;
|
|
39
|
+
}
|
|
40
|
+
/** Retry upstream fetch until success, non-retryable status, or deadline. */
|
|
41
|
+
export async function fetchUpstreamWithRetry(url, init, deps = {}) {
|
|
42
|
+
const cfg = resolveLlmRetryConfig();
|
|
43
|
+
const delayMs = deps.delayMs ?? cfg.delayMs;
|
|
44
|
+
const maxMs = deps.maxMs ?? cfg.maxMs;
|
|
45
|
+
const sleepFn = deps.sleep ?? defaultSleep;
|
|
46
|
+
const nowFn = deps.now ?? Date.now;
|
|
47
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
48
|
+
const log = deps.log ?? ((msg) => console.error(msg));
|
|
49
|
+
let attempt = 0;
|
|
50
|
+
let deadline;
|
|
51
|
+
while (true) {
|
|
52
|
+
attempt += 1;
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetchFn(url, init);
|
|
55
|
+
if (!isRetryableLlmFailure(res.status)) {
|
|
56
|
+
return res;
|
|
57
|
+
}
|
|
58
|
+
if (deadline === undefined) {
|
|
59
|
+
deadline = nowFn() + maxMs;
|
|
60
|
+
}
|
|
61
|
+
const remaining = deadline - nowFn();
|
|
62
|
+
if (remaining <= 0) {
|
|
63
|
+
return res;
|
|
64
|
+
}
|
|
65
|
+
const waitMs = Math.min(delayMs, remaining);
|
|
66
|
+
log(`ChimpHands LLM proxy: upstream ${res.status}, retry in ${Math.round(waitMs / 1000)}s (attempt ${attempt}, ~${formatRemainingMs(remaining)} remaining)`);
|
|
67
|
+
try {
|
|
68
|
+
await res.arrayBuffer();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* ignore body drain errors */
|
|
72
|
+
}
|
|
73
|
+
await sleepFn(waitMs);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (!isRetryableLlmNetworkError(err)) {
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
if (deadline === undefined) {
|
|
81
|
+
deadline = nowFn() + maxMs;
|
|
82
|
+
}
|
|
83
|
+
const remaining = deadline - nowFn();
|
|
84
|
+
if (remaining <= 0) {
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
const waitMs = Math.min(delayMs, remaining);
|
|
88
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
89
|
+
log(`ChimpHands LLM proxy: upstream network error (${detail}), retry in ${Math.round(waitMs / 1000)}s (attempt ${attempt}, ~${formatRemainingMs(remaining)} remaining)`);
|
|
90
|
+
await sleepFn(waitMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function stripHopByHopHeaders(raw) {
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
const lower = key.toLowerCase();
|
|
100
|
+
if (lower === "host" || lower === "connection" || lower === "keep-alive" || lower === "transfer-encoding") {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
async function readRequestBody(req) {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
for await (const chunk of req) {
|
|
110
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
111
|
+
}
|
|
112
|
+
return chunks.length ? Buffer.concat(chunks) : undefined;
|
|
113
|
+
}
|
|
114
|
+
/** Start a localhost proxy that forwards to FeatureService /v1 with retry. */
|
|
115
|
+
export async function startLlmRetryProxy(upstreamLlmBase, deps = {}) {
|
|
116
|
+
const upstream = new URL(upstreamLlmBase.replace(/\/$/, "") + "/");
|
|
117
|
+
const upstreamOrigin = upstream.origin;
|
|
118
|
+
const server = http.createServer((req, res) => {
|
|
119
|
+
void (async () => {
|
|
120
|
+
try {
|
|
121
|
+
const pathWithQuery = req.url || "/";
|
|
122
|
+
const targetUrl = new URL(pathWithQuery, upstreamOrigin).toString();
|
|
123
|
+
const method = req.method || "GET";
|
|
124
|
+
const body = await readRequestBody(req);
|
|
125
|
+
const headers = stripHopByHopHeaders(req.headers);
|
|
126
|
+
const init = {
|
|
127
|
+
method,
|
|
128
|
+
headers,
|
|
129
|
+
body: body && method !== "GET" && method !== "HEAD" ? new Uint8Array(body) : undefined,
|
|
130
|
+
};
|
|
131
|
+
const upstreamRes = await fetchUpstreamWithRetry(targetUrl, init, deps);
|
|
132
|
+
const responseHeaders = {};
|
|
133
|
+
upstreamRes.headers.forEach((value, key) => {
|
|
134
|
+
if (key.toLowerCase() === "transfer-encoding")
|
|
135
|
+
return;
|
|
136
|
+
responseHeaders[key] = value;
|
|
137
|
+
});
|
|
138
|
+
res.writeHead(upstreamRes.status, responseHeaders);
|
|
139
|
+
if (upstreamRes.body) {
|
|
140
|
+
const reader = upstreamRes.body.getReader();
|
|
141
|
+
while (true) {
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
if (done)
|
|
144
|
+
break;
|
|
145
|
+
if (value?.length) {
|
|
146
|
+
res.write(Buffer.from(value));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
res.end();
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
if (!res.headersSent) {
|
|
155
|
+
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
|
|
156
|
+
}
|
|
157
|
+
res.end(msg);
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
});
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
server.once("error", reject);
|
|
163
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
164
|
+
});
|
|
165
|
+
const addr = server.address();
|
|
166
|
+
if (!addr || typeof addr === "string") {
|
|
167
|
+
server.close();
|
|
168
|
+
throw new Error("ChimpHands LLM retry proxy failed to bind");
|
|
169
|
+
}
|
|
170
|
+
const baseUrl = `http://127.0.0.1:${addr.port}/v1`;
|
|
171
|
+
return {
|
|
172
|
+
baseUrl,
|
|
173
|
+
stop: () => new Promise((resolve, reject) => {
|
|
174
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
175
|
+
}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import { fetchUpstreamWithRetry, isChimphandsLlmRetryEnabled, isRetryableLlmFailure, isRetryableLlmNetworkError, startLlmRetryProxy, } from "./llmRetryProxy.js";
|
|
5
|
+
describe("isRetryableLlmFailure", () => {
|
|
6
|
+
it("retries 502/503/504 only", () => {
|
|
7
|
+
assert.equal(isRetryableLlmFailure(503), true);
|
|
8
|
+
assert.equal(isRetryableLlmFailure(502), true);
|
|
9
|
+
assert.equal(isRetryableLlmFailure(504), true);
|
|
10
|
+
assert.equal(isRetryableLlmFailure(500), false);
|
|
11
|
+
assert.equal(isRetryableLlmFailure(429), false);
|
|
12
|
+
assert.equal(isRetryableLlmFailure(401), false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("isRetryableLlmNetworkError", () => {
|
|
16
|
+
it("detects common network failures", () => {
|
|
17
|
+
assert.equal(isRetryableLlmNetworkError(Object.assign(new Error("fetch failed"), { cause: "x" })), true);
|
|
18
|
+
assert.equal(isRetryableLlmNetworkError(Object.assign(new Error("x"), { code: "ECONNREFUSED" })), true);
|
|
19
|
+
assert.equal(isRetryableLlmNetworkError(new Error("401 Unauthorized")), false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("isChimphandsLlmRetryEnabled", () => {
|
|
23
|
+
it("defaults to enabled", () => {
|
|
24
|
+
const prev = process.env.CHIMPHANDS_LLM_RETRY;
|
|
25
|
+
delete process.env.CHIMPHANDS_LLM_RETRY;
|
|
26
|
+
assert.equal(isChimphandsLlmRetryEnabled(), true);
|
|
27
|
+
process.env.CHIMPHANDS_LLM_RETRY = "0";
|
|
28
|
+
assert.equal(isChimphandsLlmRetryEnabled(), false);
|
|
29
|
+
if (prev === undefined)
|
|
30
|
+
delete process.env.CHIMPHANDS_LLM_RETRY;
|
|
31
|
+
else
|
|
32
|
+
process.env.CHIMPHANDS_LLM_RETRY = prev;
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("fetchUpstreamWithRetry", () => {
|
|
36
|
+
it("503 twice then 200 succeeds after two waits", async () => {
|
|
37
|
+
let calls = 0;
|
|
38
|
+
const sleeps = [];
|
|
39
|
+
const res = await fetchUpstreamWithRetry("http://example.test/v1/responses", { method: "POST" }, {
|
|
40
|
+
delayMs: 30,
|
|
41
|
+
maxMs: 300,
|
|
42
|
+
sleep: async (ms) => {
|
|
43
|
+
sleeps.push(ms);
|
|
44
|
+
},
|
|
45
|
+
fetchFn: async () => {
|
|
46
|
+
calls += 1;
|
|
47
|
+
if (calls <= 2) {
|
|
48
|
+
return new Response("unavailable", { status: 503 });
|
|
49
|
+
}
|
|
50
|
+
return new Response('{"ok":true}', { status: 200, headers: { "Content-Type": "application/json" } });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
assert.equal(res.status, 200);
|
|
54
|
+
assert.equal(calls, 3);
|
|
55
|
+
assert.deepEqual(sleeps, [30, 30]);
|
|
56
|
+
});
|
|
57
|
+
it("503 until deadline returns last 503", async () => {
|
|
58
|
+
let calls = 0;
|
|
59
|
+
let now = 0;
|
|
60
|
+
const res = await fetchUpstreamWithRetry("http://example.test/v1/responses", { method: "POST" }, {
|
|
61
|
+
delayMs: 100,
|
|
62
|
+
maxMs: 250,
|
|
63
|
+
now: () => now,
|
|
64
|
+
sleep: async (ms) => {
|
|
65
|
+
now += ms;
|
|
66
|
+
},
|
|
67
|
+
fetchFn: async () => {
|
|
68
|
+
calls += 1;
|
|
69
|
+
return new Response("unavailable", { status: 503 });
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
assert.equal(res.status, 503);
|
|
73
|
+
assert.equal(calls, 4);
|
|
74
|
+
});
|
|
75
|
+
it("network error then success", async () => {
|
|
76
|
+
let calls = 0;
|
|
77
|
+
const res = await fetchUpstreamWithRetry("http://example.test/v1/responses", { method: "POST" }, {
|
|
78
|
+
delayMs: 10,
|
|
79
|
+
maxMs: 100,
|
|
80
|
+
sleep: async () => { },
|
|
81
|
+
fetchFn: async () => {
|
|
82
|
+
calls += 1;
|
|
83
|
+
if (calls === 1) {
|
|
84
|
+
throw Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
|
|
85
|
+
}
|
|
86
|
+
return new Response("ok", { status: 200 });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
assert.equal(res.status, 200);
|
|
90
|
+
assert.equal(calls, 2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("startLlmRetryProxy", () => {
|
|
94
|
+
it("503 twice then 200 via proxy", async () => {
|
|
95
|
+
let upstreamCalls = 0;
|
|
96
|
+
const upstream = http.createServer((_req, res) => {
|
|
97
|
+
upstreamCalls += 1;
|
|
98
|
+
if (upstreamCalls <= 2) {
|
|
99
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
100
|
+
res.end("down");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
104
|
+
res.end('{"ok":true}');
|
|
105
|
+
});
|
|
106
|
+
await new Promise((resolve) => upstream.listen(0, "127.0.0.1", () => resolve()));
|
|
107
|
+
const upstreamPort = upstream.address().port;
|
|
108
|
+
const proxy = await startLlmRetryProxy(`http://127.0.0.1:${upstreamPort}/v1`, {
|
|
109
|
+
delayMs: 20,
|
|
110
|
+
maxMs: 200,
|
|
111
|
+
sleep: async () => { },
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`${proxy.baseUrl}/responses`, { method: "POST", body: "{}" });
|
|
115
|
+
assert.equal(res.status, 200);
|
|
116
|
+
assert.equal(await res.text(), '{"ok":true}');
|
|
117
|
+
assert.equal(upstreamCalls, 3);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await proxy.stop();
|
|
121
|
+
await new Promise((resolve) => upstream.close(() => resolve()));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it("streams chunked success without duplication after 503", async () => {
|
|
125
|
+
let upstreamCalls = 0;
|
|
126
|
+
const upstream = http.createServer((_req, res) => {
|
|
127
|
+
upstreamCalls += 1;
|
|
128
|
+
if (upstreamCalls === 1) {
|
|
129
|
+
res.writeHead(503);
|
|
130
|
+
res.end("down");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
134
|
+
res.write("data: one\n\n");
|
|
135
|
+
res.write("data: two\n\n");
|
|
136
|
+
res.end();
|
|
137
|
+
});
|
|
138
|
+
await new Promise((resolve) => upstream.listen(0, "127.0.0.1", () => resolve()));
|
|
139
|
+
const upstreamPort = upstream.address().port;
|
|
140
|
+
const proxy = await startLlmRetryProxy(`http://127.0.0.1:${upstreamPort}/v1`, {
|
|
141
|
+
delayMs: 10,
|
|
142
|
+
maxMs: 100,
|
|
143
|
+
sleep: async () => { },
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch(`${proxy.baseUrl}/chat/completions`, { method: "POST", body: "{}" });
|
|
147
|
+
assert.equal(res.status, 200);
|
|
148
|
+
assert.equal(await res.text(), "data: one\n\ndata: two\n\n");
|
|
149
|
+
assert.equal(upstreamCalls, 2);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await proxy.stop();
|
|
153
|
+
await new Promise((resolve) => upstream.close(() => resolve()));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
package/dist/chimphands/run.js
CHANGED
|
@@ -11,6 +11,7 @@ import http from "node:http";
|
|
|
11
11
|
import https from "node:https";
|
|
12
12
|
import { URL } from "node:url";
|
|
13
13
|
import { getBackendUrl, requireApiKey } from "../core/client.js";
|
|
14
|
+
import { isChimphandsLlmRetryEnabled, startLlmRetryProxy } from "./llmRetryProxy.js";
|
|
14
15
|
const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
|
|
15
16
|
const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
|
|
16
17
|
const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
|
|
@@ -960,8 +961,10 @@ function detectWorkingBranchFromToolOutput(output) {
|
|
|
960
961
|
pullRequestUrl: prMatch?.[0],
|
|
961
962
|
};
|
|
962
963
|
}
|
|
963
|
-
function writeOpencodeConfig(backend, apiKey, boot) {
|
|
964
|
-
const llmBase = (
|
|
964
|
+
function writeOpencodeConfig(backend, apiKey, boot, llmBaseOverride) {
|
|
965
|
+
const llmBase = (llmBaseOverride ||
|
|
966
|
+
bootStr(boot, "llm_base_url", "llmBaseUrl") ||
|
|
967
|
+
`${backend}/v1`).replace(/\/$/, "");
|
|
965
968
|
const llmKey = apiKey || bootStr(boot, "llm_api_key", "llmApiKey");
|
|
966
969
|
const modelId = resolveOpencodeModelId(boot);
|
|
967
970
|
const model = `${TESTCHIMP_PROVIDER_ID}/${modelId}`;
|
|
@@ -980,6 +983,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
980
983
|
const providerOptions = {
|
|
981
984
|
apiKey: llmKey,
|
|
982
985
|
baseURL: llmBase,
|
|
986
|
+
// Retry proxy may wait up to 5 min; allow long completions after upstream is healthy.
|
|
987
|
+
timeout: 900_000,
|
|
983
988
|
};
|
|
984
989
|
if (sessionId) {
|
|
985
990
|
providerOptions.headers = {
|
|
@@ -1053,6 +1058,35 @@ async function abortOpencodeSession(attachUrl, opencodeSessionId) {
|
|
|
1053
1058
|
console.error(`ChimpHands OpenCode session abort failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1054
1059
|
}
|
|
1055
1060
|
}
|
|
1061
|
+
/** Deliver a user message into an active OpenCode turn without aborting it. */
|
|
1062
|
+
async function injectOpencodeUserMessage(attachUrl, opencodeSessionId, content) {
|
|
1063
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
1064
|
+
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/prompt_async`;
|
|
1065
|
+
try {
|
|
1066
|
+
const res = await fetch(url, {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
headers: {
|
|
1069
|
+
Accept: "application/json",
|
|
1070
|
+
"Content-Type": "application/json",
|
|
1071
|
+
"x-opencode-directory": process.cwd(),
|
|
1072
|
+
},
|
|
1073
|
+
body: JSON.stringify({
|
|
1074
|
+
parts: [{ type: "text", text: content }],
|
|
1075
|
+
}),
|
|
1076
|
+
});
|
|
1077
|
+
if (res.status === 204 || res.ok) {
|
|
1078
|
+
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1079
|
+
console.error(`ChimpHands injected mid-turn user message into OpenCode session ${opencodeSessionId}: ${JSON.stringify(preview)}`);
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
console.error(`ChimpHands mid-turn inject HTTP ${res.status} for session ${opencodeSessionId}`);
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
console.error(`ChimpHands mid-turn inject failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1056
1090
|
function parseInboundUserMessage(data) {
|
|
1057
1091
|
if (!data || typeof data !== "object")
|
|
1058
1092
|
return null;
|
|
@@ -1732,7 +1766,16 @@ export async function runChimphands(opts) {
|
|
|
1732
1766
|
process.env.TESTCHIMP_USER_ID = userId;
|
|
1733
1767
|
}
|
|
1734
1768
|
mkdirSync(".opencode", { recursive: true });
|
|
1735
|
-
const
|
|
1769
|
+
const upstreamLlmBase = (bootStr(boot, "llm_base_url", "llmBaseUrl") || `${backend}/v1`).replace(/\/$/, "");
|
|
1770
|
+
let llmBaseForOpencode = upstreamLlmBase;
|
|
1771
|
+
let stopLlmProxy = null;
|
|
1772
|
+
if (isChimphandsLlmRetryEnabled()) {
|
|
1773
|
+
const proxy = await startLlmRetryProxy(upstreamLlmBase);
|
|
1774
|
+
llmBaseForOpencode = proxy.baseUrl;
|
|
1775
|
+
stopLlmProxy = proxy.stop;
|
|
1776
|
+
console.error(`ChimpHands LLM retry proxy: ${llmBaseForOpencode} -> ${upstreamLlmBase}`);
|
|
1777
|
+
}
|
|
1778
|
+
const opencodeModel = writeOpencodeConfig(backend, apiKey, boot, llmBaseForOpencode);
|
|
1736
1779
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
1737
1780
|
if (attachUrl) {
|
|
1738
1781
|
console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
|
|
@@ -1775,6 +1818,8 @@ export async function runChimphands(opts) {
|
|
|
1775
1818
|
let cancelTurnRequested = false;
|
|
1776
1819
|
let activeOpencodeChild = null;
|
|
1777
1820
|
let agentTurnInProgress = false;
|
|
1821
|
+
/** Mid-turn messages waiting for an OpenCode session id before prompt_async inject. */
|
|
1822
|
+
const pendingMidTurnInject = [];
|
|
1778
1823
|
/** Wakes waitForNextPrompt when idle is signaled (SSE) or a user message arrives. */
|
|
1779
1824
|
let wakeWaitForPrompt = null;
|
|
1780
1825
|
const wakePromptWaiter = () => {
|
|
@@ -1788,6 +1833,48 @@ export async function runChimphands(opts) {
|
|
|
1788
1833
|
activeOpencodeChild = child;
|
|
1789
1834
|
},
|
|
1790
1835
|
};
|
|
1836
|
+
/** Abort in-flight OpenCode turn (session HTTP abort + kill `opencode run` child). */
|
|
1837
|
+
const abortActiveTurn = () => {
|
|
1838
|
+
cancelTurnRequested = true;
|
|
1839
|
+
const sessionToAbort = opencodeSessionId?.trim();
|
|
1840
|
+
if (attachUrl && sessionToAbort) {
|
|
1841
|
+
void abortOpencodeSession(attachUrl, sessionToAbort);
|
|
1842
|
+
}
|
|
1843
|
+
const ch = activeOpencodeChild;
|
|
1844
|
+
if (ch && !ch.killed) {
|
|
1845
|
+
try {
|
|
1846
|
+
ch.kill("SIGTERM");
|
|
1847
|
+
setTimeout(() => {
|
|
1848
|
+
try {
|
|
1849
|
+
if (!ch.killed)
|
|
1850
|
+
ch.kill("SIGKILL");
|
|
1851
|
+
}
|
|
1852
|
+
catch {
|
|
1853
|
+
/* ignore */
|
|
1854
|
+
}
|
|
1855
|
+
}, 2000);
|
|
1856
|
+
}
|
|
1857
|
+
catch {
|
|
1858
|
+
/* ignore */
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
const flushPendingMidTurnInject = () => {
|
|
1863
|
+
if (!attachUrl || !agentTurnInProgress)
|
|
1864
|
+
return;
|
|
1865
|
+
const sessionToInject = opencodeSessionId?.trim();
|
|
1866
|
+
if (!sessionToInject)
|
|
1867
|
+
return;
|
|
1868
|
+
while (pendingMidTurnInject.length) {
|
|
1869
|
+
const content = pendingMidTurnInject.shift();
|
|
1870
|
+
void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
|
|
1871
|
+
if (!ok) {
|
|
1872
|
+
queue.push(content);
|
|
1873
|
+
wakePromptWaiter();
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1791
1878
|
const enqueueUserMessage = (msg) => {
|
|
1792
1879
|
const id = msg.id?.trim();
|
|
1793
1880
|
if (id) {
|
|
@@ -1798,12 +1885,30 @@ export async function runChimphands(opts) {
|
|
|
1798
1885
|
const content = normalizeUserMessage(msg.content || "");
|
|
1799
1886
|
if (!content)
|
|
1800
1887
|
return;
|
|
1801
|
-
queue.push(content);
|
|
1802
1888
|
lastUserActivity = Date.now();
|
|
1803
1889
|
idle = false;
|
|
1804
|
-
wakePromptWaiter();
|
|
1805
1890
|
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1891
|
+
if (agentTurnInProgress && attachUrl) {
|
|
1892
|
+
const sessionToInject = opencodeSessionId?.trim();
|
|
1893
|
+
if (sessionToInject) {
|
|
1894
|
+
console.error(`ChimpHands delivering mid-turn user message via OpenCode prompt_async (turnActive=true): ${JSON.stringify(preview)}`);
|
|
1895
|
+
void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
|
|
1896
|
+
if (!ok) {
|
|
1897
|
+
queue.push(content);
|
|
1898
|
+
wakePromptWaiter();
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
postEvent(ROLE_STATUS, "Message sent to agent…", { status: STATUS_RUNNING });
|
|
1902
|
+
});
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
pendingMidTurnInject.push(content);
|
|
1906
|
+
console.error(`ChimpHands holding mid-turn user message until OpenCode session id is available: ${JSON.stringify(preview)}`);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
queue.push(content);
|
|
1806
1910
|
console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
|
|
1911
|
+
wakePromptWaiter();
|
|
1807
1912
|
};
|
|
1808
1913
|
const pollPendingUserMessages = async () => {
|
|
1809
1914
|
try {
|
|
@@ -1869,29 +1974,7 @@ export async function runChimphands(opts) {
|
|
|
1869
1974
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
1870
1975
|
const handleCancelTurn = () => {
|
|
1871
1976
|
console.error(`ChimpHands cancel_turn received for session ${sessionId}`);
|
|
1872
|
-
|
|
1873
|
-
const sessionToAbort = opencodeSessionId?.trim();
|
|
1874
|
-
if (attachUrl && sessionToAbort) {
|
|
1875
|
-
void abortOpencodeSession(attachUrl, sessionToAbort);
|
|
1876
|
-
}
|
|
1877
|
-
const ch = activeOpencodeChild;
|
|
1878
|
-
if (ch && !ch.killed) {
|
|
1879
|
-
try {
|
|
1880
|
-
ch.kill("SIGTERM");
|
|
1881
|
-
setTimeout(() => {
|
|
1882
|
-
try {
|
|
1883
|
-
if (!ch.killed)
|
|
1884
|
-
ch.kill("SIGKILL");
|
|
1885
|
-
}
|
|
1886
|
-
catch {
|
|
1887
|
-
/* ignore */
|
|
1888
|
-
}
|
|
1889
|
-
}, 2000);
|
|
1890
|
-
}
|
|
1891
|
-
catch {
|
|
1892
|
-
/* ignore */
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1977
|
+
abortActiveTurn();
|
|
1895
1978
|
};
|
|
1896
1979
|
const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
|
|
1897
1980
|
onUserMessage: enqueueUserMessage,
|
|
@@ -1928,6 +2011,12 @@ export async function runChimphands(opts) {
|
|
|
1928
2011
|
wakePromptWaiter();
|
|
1929
2012
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1930
2013
|
await poster.flush();
|
|
2014
|
+
if (stopLlmProxy) {
|
|
2015
|
+
await stopLlmProxy().catch((err) => {
|
|
2016
|
+
console.error(`ChimpHands LLM retry proxy stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2017
|
+
});
|
|
2018
|
+
stopLlmProxy = null;
|
|
2019
|
+
}
|
|
1931
2020
|
if (runtimeId) {
|
|
1932
2021
|
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
1933
2022
|
runtimeId,
|
|
@@ -2007,6 +2096,7 @@ export async function runChimphands(opts) {
|
|
|
2007
2096
|
while (prompt) {
|
|
2008
2097
|
cancelTurnRequested = false;
|
|
2009
2098
|
agentTurnInProgress = true;
|
|
2099
|
+
flushPendingMidTurnInject();
|
|
2010
2100
|
let useOpencodeSessionId = opencodeSessionId;
|
|
2011
2101
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
2012
2102
|
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
@@ -2021,6 +2111,7 @@ export async function runChimphands(opts) {
|
|
|
2021
2111
|
sessionId,
|
|
2022
2112
|
opencodeSessionId: id,
|
|
2023
2113
|
}).catch(() => { });
|
|
2114
|
+
flushPendingMidTurnInject();
|
|
2024
2115
|
},
|
|
2025
2116
|
onWorkingBranch: noteWorkingBranch,
|
|
2026
2117
|
postEvent: turnPostEvent,
|
|
@@ -2040,6 +2131,7 @@ export async function runChimphands(opts) {
|
|
|
2040
2131
|
sessionId,
|
|
2041
2132
|
opencodeSessionId: id,
|
|
2042
2133
|
}).catch(() => { });
|
|
2134
|
+
flushPendingMidTurnInject();
|
|
2043
2135
|
},
|
|
2044
2136
|
onWorkingBranch: noteWorkingBranch,
|
|
2045
2137
|
postEvent: turnPostEvent,
|
|
@@ -2047,6 +2139,9 @@ export async function runChimphands(opts) {
|
|
|
2047
2139
|
}, attachUrl);
|
|
2048
2140
|
}
|
|
2049
2141
|
agentTurnInProgress = false;
|
|
2142
|
+
while (pendingMidTurnInject.length) {
|
|
2143
|
+
queue.push(pendingMidTurnInject.shift());
|
|
2144
|
+
}
|
|
2050
2145
|
await poster.flush();
|
|
2051
2146
|
if (result.opencodeSessionId) {
|
|
2052
2147
|
opencodeSessionId = result.opencodeSessionId;
|
package/dist/cli/program.js
CHANGED
|
@@ -292,6 +292,48 @@ export function buildCliProgram() {
|
|
|
292
292
|
const out = await runTool("fetch-execution-report", merged, { postMcp });
|
|
293
293
|
console.log(out);
|
|
294
294
|
});
|
|
295
|
+
program
|
|
296
|
+
.command("upload-attachment")
|
|
297
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "upload-attachment").description)
|
|
298
|
+
.addOption(jsonInputOption())
|
|
299
|
+
.option("--file <path>", "Path to file to upload (e.g. screenshot PNG)")
|
|
300
|
+
.option("--filename <name>", "Original filename for extension inference")
|
|
301
|
+
.option("--content-type <type>", "MIME type (default application/octet-stream)")
|
|
302
|
+
.action(async (opts) => {
|
|
303
|
+
const body = {};
|
|
304
|
+
if (opts.file)
|
|
305
|
+
body.file = String(opts.file);
|
|
306
|
+
if (opts.filename)
|
|
307
|
+
body.filename = String(opts.filename);
|
|
308
|
+
if (opts.contentType)
|
|
309
|
+
body.contentType = String(opts.contentType);
|
|
310
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
311
|
+
if (!merged.file || String(merged.file).trim() === "") {
|
|
312
|
+
throw new Error("file is required (--file)");
|
|
313
|
+
}
|
|
314
|
+
const out = await runTool("upload-attachment", {
|
|
315
|
+
file: String(merged.file).trim(),
|
|
316
|
+
...(merged.filename ? { filename: String(merged.filename) } : {}),
|
|
317
|
+
...(merged.contentType ? { contentType: String(merged.contentType) } : {}),
|
|
318
|
+
}, { postMcp });
|
|
319
|
+
console.log(out);
|
|
320
|
+
});
|
|
321
|
+
program
|
|
322
|
+
.command("get-batch-view-url")
|
|
323
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-batch-view-url").description)
|
|
324
|
+
.addOption(jsonInputOption())
|
|
325
|
+
.option("--batch-invocation-id <id>", "Batch invocation id")
|
|
326
|
+
.action(async (opts) => {
|
|
327
|
+
const body = {};
|
|
328
|
+
if (opts.batchInvocationId)
|
|
329
|
+
body.batchInvocationId = String(opts.batchInvocationId);
|
|
330
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
331
|
+
if (!merged.batchInvocationId || String(merged.batchInvocationId).trim() === "") {
|
|
332
|
+
throw new Error("batchInvocationId is required (--batch-invocation-id)");
|
|
333
|
+
}
|
|
334
|
+
const out = await runTool("get-batch-view-url", { batchInvocationId: String(merged.batchInvocationId).trim() }, { postMcp });
|
|
335
|
+
console.log(out);
|
|
336
|
+
});
|
|
295
337
|
addAgentTraceabilityOptions(program
|
|
296
338
|
.command("create-user-story")
|
|
297
339
|
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "create-user-story").description)
|
package/dist/core/schemas.d.ts
CHANGED
|
@@ -1972,3 +1972,13 @@ export declare const listApiOperationInteractionsInput: z.ZodObject<{
|
|
|
1972
1972
|
}>>;
|
|
1973
1973
|
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
1974
1974
|
}, z.core.$strip>;
|
|
1975
|
+
/** Max decoded upload size for agent evidence (matches backend ExploreSnapsPathUtil). */
|
|
1976
|
+
export declare const MAX_AGENT_ATTACHMENT_BYTES: number;
|
|
1977
|
+
export declare const uploadAttachmentInput: z.ZodObject<{
|
|
1978
|
+
file: z.ZodString;
|
|
1979
|
+
filename: z.ZodOptional<z.ZodString>;
|
|
1980
|
+
contentType: z.ZodOptional<z.ZodString>;
|
|
1981
|
+
}, z.core.$strip>;
|
|
1982
|
+
export declare const getBatchViewUrlInput: z.ZodObject<{
|
|
1983
|
+
batchInvocationId: z.ZodString;
|
|
1984
|
+
}, z.core.$strip>;
|
package/dist/core/schemas.js
CHANGED
|
@@ -990,3 +990,13 @@ export const listApiOperationInteractionsInput = z
|
|
|
990
990
|
});
|
|
991
991
|
}
|
|
992
992
|
});
|
|
993
|
+
/** Max decoded upload size for agent evidence (matches backend ExploreSnapsPathUtil). */
|
|
994
|
+
export const MAX_AGENT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
995
|
+
export const uploadAttachmentInput = z.object({
|
|
996
|
+
file: z.string().min(1),
|
|
997
|
+
filename: z.string().min(1).optional(),
|
|
998
|
+
contentType: z.string().min(1).optional(),
|
|
999
|
+
});
|
|
1000
|
+
export const getBatchViewUrlInput = z.object({
|
|
1001
|
+
batchInvocationId: z.string().min(1),
|
|
1002
|
+
});
|
package/dist/core/tools.js
CHANGED
|
@@ -1467,6 +1467,43 @@ export const TOOL_DEFINITIONS = [
|
|
|
1467
1467
|
return postMcp("/api/mcp/list_api_operation_interactions", body);
|
|
1468
1468
|
},
|
|
1469
1469
|
},
|
|
1470
|
+
{
|
|
1471
|
+
kebab: "upload-attachment",
|
|
1472
|
+
description: "Upload a file (e.g. agent screenshot evidence) to explore-snaps and return a stable view URL. " +
|
|
1473
|
+
"Pass --file <path>; optional --filename and --content-type. Response includes viewUrl to paste in chat.",
|
|
1474
|
+
inputSchema: S.uploadAttachmentInput,
|
|
1475
|
+
execute: async (args, { postMcp }) => {
|
|
1476
|
+
const a = args;
|
|
1477
|
+
const { readFile, stat } = await import("node:fs/promises");
|
|
1478
|
+
const { basename } = await import("node:path");
|
|
1479
|
+
const fileStat = await stat(a.file);
|
|
1480
|
+
if (fileStat.size > S.MAX_AGENT_ATTACHMENT_BYTES) {
|
|
1481
|
+
throw new Error(`File exceeds maximum size of ${S.MAX_AGENT_ATTACHMENT_BYTES} bytes (${fileStat.size} bytes)`);
|
|
1482
|
+
}
|
|
1483
|
+
const bytes = await readFile(a.file);
|
|
1484
|
+
const body = {
|
|
1485
|
+
fileBase64: bytes.toString("base64"),
|
|
1486
|
+
};
|
|
1487
|
+
const filename = a.filename?.trim() || basename(a.file);
|
|
1488
|
+
if (filename)
|
|
1489
|
+
body.filename = filename;
|
|
1490
|
+
if (a.contentType?.trim())
|
|
1491
|
+
body.contentType = a.contentType.trim();
|
|
1492
|
+
return postMcp("/api/mcp/upload_attachment", body);
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
kebab: "get-batch-view-url",
|
|
1497
|
+
description: "Resolve the TestChimp batch execution viewer deeplink for the authenticated project. " +
|
|
1498
|
+
"Pass --batch-invocation-id. Returns batchViewUrl for messaging back to the user.",
|
|
1499
|
+
inputSchema: S.getBatchViewUrlInput,
|
|
1500
|
+
execute: async (args, { postMcp }) => {
|
|
1501
|
+
const a = args;
|
|
1502
|
+
return postMcp("/api/mcp/get_batch_view_url", {
|
|
1503
|
+
batchInvocationId: a.batchInvocationId.trim(),
|
|
1504
|
+
});
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1470
1507
|
];
|
|
1471
1508
|
const TOOL_BY_KEBAB = new Map(TOOL_DEFINITIONS.map((t) => [t.kebab, t]));
|
|
1472
1509
|
export function getToolDefinition(kebab) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testchimp/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
|
+
"test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js",
|
|
16
17
|
"watch": "tsc --watch",
|
|
17
18
|
"start": "node dist/bin/testchimp.js",
|
|
18
19
|
"prepublishOnly": "npm run build"
|