@testchimp/cli 0.1.66 → 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 +100 -35
- 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}`);
|
|
@@ -1773,10 +1816,10 @@ export async function runChimphands(opts) {
|
|
|
1773
1816
|
let lastUserActivity = Date.now();
|
|
1774
1817
|
let exitCode;
|
|
1775
1818
|
let cancelTurnRequested = false;
|
|
1776
|
-
/** True when cancel was triggered by a mid-turn user message (steer), not Stop. */
|
|
1777
|
-
let steerAbortRequested = false;
|
|
1778
1819
|
let activeOpencodeChild = null;
|
|
1779
1820
|
let agentTurnInProgress = false;
|
|
1821
|
+
/** Mid-turn messages waiting for an OpenCode session id before prompt_async inject. */
|
|
1822
|
+
const pendingMidTurnInject = [];
|
|
1780
1823
|
/** Wakes waitForNextPrompt when idle is signaled (SSE) or a user message arrives. */
|
|
1781
1824
|
let wakeWaitForPrompt = null;
|
|
1782
1825
|
const wakePromptWaiter = () => {
|
|
@@ -1791,11 +1834,8 @@ export async function runChimphands(opts) {
|
|
|
1791
1834
|
},
|
|
1792
1835
|
};
|
|
1793
1836
|
/** Abort in-flight OpenCode turn (session HTTP abort + kill `opencode run` child). */
|
|
1794
|
-
const abortActiveTurn = (
|
|
1837
|
+
const abortActiveTurn = () => {
|
|
1795
1838
|
cancelTurnRequested = true;
|
|
1796
|
-
if (reason === "steer") {
|
|
1797
|
-
steerAbortRequested = true;
|
|
1798
|
-
}
|
|
1799
1839
|
const sessionToAbort = opencodeSessionId?.trim();
|
|
1800
1840
|
if (attachUrl && sessionToAbort) {
|
|
1801
1841
|
void abortOpencodeSession(attachUrl, sessionToAbort);
|
|
@@ -1819,6 +1859,22 @@ export async function runChimphands(opts) {
|
|
|
1819
1859
|
}
|
|
1820
1860
|
}
|
|
1821
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
|
+
};
|
|
1822
1878
|
const enqueueUserMessage = (msg) => {
|
|
1823
1879
|
const id = msg.id?.trim();
|
|
1824
1880
|
if (id) {
|
|
@@ -1829,17 +1885,29 @@ export async function runChimphands(opts) {
|
|
|
1829
1885
|
const content = normalizeUserMessage(msg.content || "");
|
|
1830
1886
|
if (!content)
|
|
1831
1887
|
return;
|
|
1832
|
-
queue.push(content);
|
|
1833
1888
|
lastUserActivity = Date.now();
|
|
1834
1889
|
idle = false;
|
|
1835
1890
|
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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;
|
|
1842
1908
|
}
|
|
1909
|
+
queue.push(content);
|
|
1910
|
+
console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
|
|
1843
1911
|
wakePromptWaiter();
|
|
1844
1912
|
};
|
|
1845
1913
|
const pollPendingUserMessages = async () => {
|
|
@@ -1906,8 +1974,7 @@ export async function runChimphands(opts) {
|
|
|
1906
1974
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
1907
1975
|
const handleCancelTurn = () => {
|
|
1908
1976
|
console.error(`ChimpHands cancel_turn received for session ${sessionId}`);
|
|
1909
|
-
|
|
1910
|
-
abortActiveTurn("cancel");
|
|
1977
|
+
abortActiveTurn();
|
|
1911
1978
|
};
|
|
1912
1979
|
const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
|
|
1913
1980
|
onUserMessage: enqueueUserMessage,
|
|
@@ -1944,6 +2011,12 @@ export async function runChimphands(opts) {
|
|
|
1944
2011
|
wakePromptWaiter();
|
|
1945
2012
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1946
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
|
+
}
|
|
1947
2020
|
if (runtimeId) {
|
|
1948
2021
|
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
1949
2022
|
runtimeId,
|
|
@@ -2022,21 +2095,12 @@ export async function runChimphands(opts) {
|
|
|
2022
2095
|
});
|
|
2023
2096
|
while (prompt) {
|
|
2024
2097
|
cancelTurnRequested = false;
|
|
2025
|
-
const wasSteered = steerAbortRequested;
|
|
2026
|
-
steerAbortRequested = false;
|
|
2027
2098
|
agentTurnInProgress = true;
|
|
2099
|
+
flushPendingMidTurnInject();
|
|
2028
2100
|
let useOpencodeSessionId = opencodeSessionId;
|
|
2029
2101
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
"The previous agent turn was interrupted because the user sent a new message.",
|
|
2033
|
-
"Stop any long-running watch/poll that no longer applies. Course-correct using this guidance:",
|
|
2034
|
-
"",
|
|
2035
|
-
prompt,
|
|
2036
|
-
].join("\n")
|
|
2037
|
-
: prompt;
|
|
2038
|
-
let effectivePrompt = wrapPromptWithContext(conversationSummary, promptForOpencode, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
2039
|
-
const turnPostEvent = (role, content, opts) => postEventForTurn(promptForOpencode, effectivePrompt, role, content, opts);
|
|
2102
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
2103
|
+
const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, effectivePrompt, role, content, opts);
|
|
2040
2104
|
// Visible in chat (not filtered as routine). OpenCode may not emit text until a
|
|
2041
2105
|
// part completes — without this the UI looks empty while the turn is running.
|
|
2042
2106
|
postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
|
|
@@ -2047,6 +2111,7 @@ export async function runChimphands(opts) {
|
|
|
2047
2111
|
sessionId,
|
|
2048
2112
|
opencodeSessionId: id,
|
|
2049
2113
|
}).catch(() => { });
|
|
2114
|
+
flushPendingMidTurnInject();
|
|
2050
2115
|
},
|
|
2051
2116
|
onWorkingBranch: noteWorkingBranch,
|
|
2052
2117
|
postEvent: turnPostEvent,
|
|
@@ -2066,6 +2131,7 @@ export async function runChimphands(opts) {
|
|
|
2066
2131
|
sessionId,
|
|
2067
2132
|
opencodeSessionId: id,
|
|
2068
2133
|
}).catch(() => { });
|
|
2134
|
+
flushPendingMidTurnInject();
|
|
2069
2135
|
},
|
|
2070
2136
|
onWorkingBranch: noteWorkingBranch,
|
|
2071
2137
|
postEvent: turnPostEvent,
|
|
@@ -2073,6 +2139,9 @@ export async function runChimphands(opts) {
|
|
|
2073
2139
|
}, attachUrl);
|
|
2074
2140
|
}
|
|
2075
2141
|
agentTurnInProgress = false;
|
|
2142
|
+
while (pendingMidTurnInject.length) {
|
|
2143
|
+
queue.push(pendingMidTurnInject.shift());
|
|
2144
|
+
}
|
|
2076
2145
|
await poster.flush();
|
|
2077
2146
|
if (result.opencodeSessionId) {
|
|
2078
2147
|
opencodeSessionId = result.opencodeSessionId;
|
|
@@ -2124,11 +2193,7 @@ export async function runChimphands(opts) {
|
|
|
2124
2193
|
exitCode = result.code || 1;
|
|
2125
2194
|
break;
|
|
2126
2195
|
}
|
|
2127
|
-
if (result.cancelled
|
|
2128
|
-
// Mid-turn user message aborted this turn — run the queued prompt next.
|
|
2129
|
-
postEvent(ROLE_STATUS, "Incorporating your message…", { status: STATUS_RUNNING });
|
|
2130
|
-
}
|
|
2131
|
-
else if (result.cancelled) {
|
|
2196
|
+
if (result.cancelled) {
|
|
2132
2197
|
postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
|
|
2133
2198
|
}
|
|
2134
2199
|
else {
|
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"
|