@testchimp/cli 0.1.66 → 0.1.68
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/midTurnSteering.d.ts +25 -0
- package/dist/chimphands/midTurnSteering.js +93 -0
- package/dist/chimphands/midTurnSteering.test.d.ts +1 -0
- package/dist/chimphands/midTurnSteering.test.js +46 -0
- package/dist/chimphands/run.js +197 -39
- package/dist/chimphands/steeringSidecar.d.ts +40 -0
- package/dist/chimphands/steeringSidecar.js +103 -0
- package/dist/chimphands/steeringSidecar.test.d.ts +1 -0
- package/dist/chimphands/steeringSidecar.test.js +28 -0
- 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
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Helpers for mid-turn user steering via OpenCode prompt_async. */
|
|
2
|
+
export type OpencodeSessionMessage = {
|
|
3
|
+
info?: {
|
|
4
|
+
id?: string;
|
|
5
|
+
role?: string;
|
|
6
|
+
};
|
|
7
|
+
parts?: Array<{
|
|
8
|
+
type?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
state?: {
|
|
11
|
+
status?: string;
|
|
12
|
+
};
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
export declare function normalizeSteeringContent(content: string): string;
|
|
16
|
+
export declare function midTurnSteeringKey(id: string | undefined, content: string): string;
|
|
17
|
+
export declare function extractUserMessageText(msg: OpencodeSessionMessage): string;
|
|
18
|
+
/** True when an assistant message after the matching user text has substantive output. */
|
|
19
|
+
export declare function userMessageHasAssistantReply(messages: OpencodeSessionMessage[], userContent: string): boolean;
|
|
20
|
+
export declare function fetchOpencodeSessionMessages(attachUrl: string, opencodeSessionId: string, cwd?: string): Promise<OpencodeSessionMessage[]>;
|
|
21
|
+
/**
|
|
22
|
+
* After a turn ends, re-queue mid-turn steering messages that prompt_async accepted
|
|
23
|
+
* but OpenCode never answered (e.g. blocked on long-running bash).
|
|
24
|
+
*/
|
|
25
|
+
export declare function releaseUnansweredMidTurnMessages(attachUrl: string, opencodeSessionId: string, midTurnInjected: Map<string, string>, queue: string[], wake: () => void, sidecarAnsweredKeys?: Set<string>): Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Helpers for mid-turn user steering via OpenCode prompt_async. */
|
|
2
|
+
export function normalizeSteeringContent(content) {
|
|
3
|
+
return content.trim();
|
|
4
|
+
}
|
|
5
|
+
export function midTurnSteeringKey(id, content) {
|
|
6
|
+
const norm = normalizeSteeringContent(content);
|
|
7
|
+
return id?.trim() || `content:${norm}`;
|
|
8
|
+
}
|
|
9
|
+
export function extractUserMessageText(msg) {
|
|
10
|
+
return (msg.parts || [])
|
|
11
|
+
.filter((p) => p.type === "text" && p.text?.trim())
|
|
12
|
+
.map((p) => p.text.trim())
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
15
|
+
/** True when an assistant message after the matching user text has substantive output. */
|
|
16
|
+
export function userMessageHasAssistantReply(messages, userContent) {
|
|
17
|
+
const normalizedTarget = normalizeSteeringContent(userContent);
|
|
18
|
+
if (!normalizedTarget)
|
|
19
|
+
return true;
|
|
20
|
+
let userIdx = -1;
|
|
21
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
22
|
+
const role = (messages[i]?.info?.role || "").toLowerCase();
|
|
23
|
+
if (role !== "user")
|
|
24
|
+
continue;
|
|
25
|
+
const text = normalizeSteeringContent(extractUserMessageText(messages[i]));
|
|
26
|
+
if (text === normalizedTarget) {
|
|
27
|
+
userIdx = i;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (userIdx < 0)
|
|
32
|
+
return false;
|
|
33
|
+
for (let i = userIdx + 1; i < messages.length; i++) {
|
|
34
|
+
const role = (messages[i]?.info?.role || "").toLowerCase();
|
|
35
|
+
if (role !== "assistant")
|
|
36
|
+
continue;
|
|
37
|
+
const hasText = (messages[i]?.parts || []).some((p) => (p.type === "text" || p.type === "reasoning") && p.text?.trim());
|
|
38
|
+
const hasCompletedTool = (messages[i]?.parts || []).some((p) => p.type === "tool" && p.state?.status === "completed");
|
|
39
|
+
if (hasText || hasCompletedTool)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export async function fetchOpencodeSessionMessages(attachUrl, opencodeSessionId, cwd = process.cwd()) {
|
|
45
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
46
|
+
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
headers: { Accept: "application/json", "x-opencode-directory": cwd },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`OpenCode session messages HTTP ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
const data = (await res.json());
|
|
54
|
+
return Array.isArray(data) ? data : [];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* After a turn ends, re-queue mid-turn steering messages that prompt_async accepted
|
|
58
|
+
* but OpenCode never answered (e.g. blocked on long-running bash).
|
|
59
|
+
*/
|
|
60
|
+
export async function releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjected, queue, wake, sidecarAnsweredKeys = new Set()) {
|
|
61
|
+
if (!midTurnInjected.size)
|
|
62
|
+
return;
|
|
63
|
+
let messages = [];
|
|
64
|
+
try {
|
|
65
|
+
messages = await fetchOpencodeSessionMessages(attachUrl, opencodeSessionId);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`ChimpHands mid-turn steering check failed — queueing unanswered injected messages: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
for (const [key, content] of midTurnInjected.entries()) {
|
|
70
|
+
if (sidecarAnsweredKeys.has(key))
|
|
71
|
+
continue;
|
|
72
|
+
queue.push(content);
|
|
73
|
+
}
|
|
74
|
+
midTurnInjected.clear();
|
|
75
|
+
wake();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const [key, content] of midTurnInjected.entries()) {
|
|
79
|
+
if (sidecarAnsweredKeys.has(key)) {
|
|
80
|
+
console.error(`ChimpHands mid-turn steering message ${key} answered by sidecar — no re-queue`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (userMessageHasAssistantReply(messages, content)) {
|
|
84
|
+
console.error(`ChimpHands mid-turn steering message ${key} was answered in-session — no re-queue`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
console.error(`ChimpHands mid-turn steering message ${key} unanswered — queueing for next turn`);
|
|
88
|
+
queue.push(content);
|
|
89
|
+
}
|
|
90
|
+
midTurnInjected.clear();
|
|
91
|
+
if (queue.length)
|
|
92
|
+
wake();
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { userMessageHasAssistantReply, } from "./midTurnSteering.js";
|
|
4
|
+
function userMsg(text) {
|
|
5
|
+
return { info: { role: "user" }, parts: [{ type: "text", text }] };
|
|
6
|
+
}
|
|
7
|
+
function assistantText(text) {
|
|
8
|
+
return { info: { role: "assistant" }, parts: [{ type: "text", text }] };
|
|
9
|
+
}
|
|
10
|
+
function assistantTool(status) {
|
|
11
|
+
return {
|
|
12
|
+
info: { role: "assistant" },
|
|
13
|
+
parts: [{ type: "tool", state: { status } }],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe("userMessageHasAssistantReply", () => {
|
|
17
|
+
it("returns false when user message has no assistant reply after it", () => {
|
|
18
|
+
const messages = [userMsg("why gcp_sa_json_path?")];
|
|
19
|
+
assert.equal(userMessageHasAssistantReply(messages, "why gcp_sa_json_path?"), false);
|
|
20
|
+
});
|
|
21
|
+
it("returns true when assistant text follows the matching user message", () => {
|
|
22
|
+
const messages = [
|
|
23
|
+
userMsg("first question"),
|
|
24
|
+
assistantText("first answer"),
|
|
25
|
+
userMsg("why gcp_sa_json_path?"),
|
|
26
|
+
assistantText("We should use WIF in CI."),
|
|
27
|
+
];
|
|
28
|
+
assert.equal(userMessageHasAssistantReply(messages, "why gcp_sa_json_path?"), true);
|
|
29
|
+
});
|
|
30
|
+
it("matches the latest user message with the same content", () => {
|
|
31
|
+
const messages = [
|
|
32
|
+
userMsg("repeat"),
|
|
33
|
+
assistantText("old answer"),
|
|
34
|
+
userMsg("repeat"),
|
|
35
|
+
];
|
|
36
|
+
assert.equal(userMessageHasAssistantReply(messages, "repeat"), false);
|
|
37
|
+
});
|
|
38
|
+
it("counts completed tool output as a reply", () => {
|
|
39
|
+
const messages = [userMsg("check auth"), assistantTool("completed")];
|
|
40
|
+
assert.equal(userMessageHasAssistantReply(messages, "check auth"), true);
|
|
41
|
+
});
|
|
42
|
+
it("ignores pending tool parts as a reply", () => {
|
|
43
|
+
const messages = [userMsg("run tests"), assistantTool("running")];
|
|
44
|
+
assert.equal(userMessageHasAssistantReply(messages, "run tests"), false);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/dist/chimphands/run.js
CHANGED
|
@@ -11,6 +11,9 @@ 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";
|
|
15
|
+
import { midTurnSteeringKey, releaseUnansweredMidTurnMessages, } from "./midTurnSteering.js";
|
|
16
|
+
import { runSteeringSidecarReply } from "./steeringSidecar.js";
|
|
14
17
|
const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
|
|
15
18
|
const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
|
|
16
19
|
const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
|
|
@@ -20,6 +23,7 @@ const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
|
|
|
20
23
|
const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
|
|
21
24
|
const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
|
|
22
25
|
const OPENCODE_AGENT_ID = "chimphands";
|
|
26
|
+
const MID_TURN_DELIVERED_STATUS = "Answering your question while the current step continues…";
|
|
23
27
|
/** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
|
|
24
28
|
const STREAM_POST_MIN_INTERVAL_MS = 150;
|
|
25
29
|
function isStreamFanoutRole(role) {
|
|
@@ -83,6 +87,11 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
|
|
|
83
87
|
- Follow plan → explicit user approval → execute (interactive default). Do not skip MCP calls or claim done without tool evidence.
|
|
84
88
|
- Export \`TESTCHIMP_EXECUTION_SOURCE=CLOUD_AGENT\` before Playwright/Mobilewright runs.
|
|
85
89
|
|
|
90
|
+
## CI runner hygiene (GitHub Actions)
|
|
91
|
+
- On hosted runners: load skill \`references/chimphands-ci-runner.md\` before compile, Docker builds, or large installs.
|
|
92
|
+
- Never \`git fetch origin\` (full fetch) — shallow single-branch fetch only; branch names are in \`.chimphands/remote-branch-names.txt\`.
|
|
93
|
+
- Reclaim disk between heavy phases (\`docker builder prune -af\` after image builds, stop compile daemons, delete temp/test artifacts). One reclaim+retry on ENOSPC — no blind loops.
|
|
94
|
+
|
|
86
95
|
## Honesty
|
|
87
96
|
- If MCP/tools fail, report the error. Never narrate success without tool output or a PR link when repo changes were needed.`;
|
|
88
97
|
function normalizeUserMessage(content) {
|
|
@@ -179,7 +188,7 @@ class AgentEventPoster {
|
|
|
179
188
|
if (opts?.pullRequestUrl)
|
|
180
189
|
body.pullRequestUrl = opts.pullRequestUrl;
|
|
181
190
|
if (opts?.toolExecution)
|
|
182
|
-
body.toolExecution = opts.toolExecution;
|
|
191
|
+
body.toolExecution = protoJsonToolExecution(opts.toolExecution);
|
|
183
192
|
const streamRole = isStreamFanoutRole(role);
|
|
184
193
|
this.chain = this.chain.then(async () => {
|
|
185
194
|
const live = !!opts?.liveStream && !opts?.durable;
|
|
@@ -206,7 +215,7 @@ class AgentEventPoster {
|
|
|
206
215
|
if (opts?.opencodeSessionId)
|
|
207
216
|
eph.opencodeSessionId = opts.opencodeSessionId;
|
|
208
217
|
if (opts?.toolExecution)
|
|
209
|
-
eph.toolExecution = opts.toolExecution;
|
|
218
|
+
eph.toolExecution = protoJsonToolExecution(opts.toolExecution);
|
|
210
219
|
try {
|
|
211
220
|
await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
|
|
212
221
|
this.ephemeralFailCount = 0;
|
|
@@ -307,14 +316,44 @@ function normalizeEpochMillis(raw) {
|
|
|
307
316
|
return raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
|
|
308
317
|
}
|
|
309
318
|
function coerceTimeoutMillis(input) {
|
|
310
|
-
if (
|
|
319
|
+
if (input == null)
|
|
320
|
+
return undefined;
|
|
321
|
+
let obj;
|
|
322
|
+
if (typeof input === "string") {
|
|
323
|
+
try {
|
|
324
|
+
const parsed = JSON.parse(input);
|
|
325
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
326
|
+
obj = parsed;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
obj = input;
|
|
335
|
+
}
|
|
336
|
+
if (!obj)
|
|
311
337
|
return undefined;
|
|
312
|
-
const raw =
|
|
338
|
+
const raw = obj.timeout ?? obj.timeoutMs ?? obj.timeout_ms;
|
|
313
339
|
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
|
|
314
340
|
if (!Number.isFinite(n) || n <= 0)
|
|
315
341
|
return undefined;
|
|
316
342
|
return Math.round(n);
|
|
317
343
|
}
|
|
344
|
+
/** Protobuf JSON encodes int64 as strings; numbers can fail JsonFormat.parse. */
|
|
345
|
+
function protoJsonToolExecution(payload) {
|
|
346
|
+
const out = { status: payload.status };
|
|
347
|
+
if (payload.toolName)
|
|
348
|
+
out.toolName = payload.toolName;
|
|
349
|
+
if (payload.startedAtMillis != null)
|
|
350
|
+
out.startedAtMillis = String(payload.startedAtMillis);
|
|
351
|
+
if (payload.timeoutMillis != null)
|
|
352
|
+
out.timeoutMillis = String(payload.timeoutMillis);
|
|
353
|
+
if (payload.endedAtMillis != null)
|
|
354
|
+
out.endedAtMillis = String(payload.endedAtMillis);
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
318
357
|
function mapToolExecutionStatus(status) {
|
|
319
358
|
const s = String(status || "").trim().toLowerCase();
|
|
320
359
|
if (!s)
|
|
@@ -960,8 +999,10 @@ function detectWorkingBranchFromToolOutput(output) {
|
|
|
960
999
|
pullRequestUrl: prMatch?.[0],
|
|
961
1000
|
};
|
|
962
1001
|
}
|
|
963
|
-
function writeOpencodeConfig(backend, apiKey, boot) {
|
|
964
|
-
const llmBase = (
|
|
1002
|
+
function writeOpencodeConfig(backend, apiKey, boot, llmBaseOverride) {
|
|
1003
|
+
const llmBase = (llmBaseOverride ||
|
|
1004
|
+
bootStr(boot, "llm_base_url", "llmBaseUrl") ||
|
|
1005
|
+
`${backend}/v1`).replace(/\/$/, "");
|
|
965
1006
|
const llmKey = apiKey || bootStr(boot, "llm_api_key", "llmApiKey");
|
|
966
1007
|
const modelId = resolveOpencodeModelId(boot);
|
|
967
1008
|
const model = `${TESTCHIMP_PROVIDER_ID}/${modelId}`;
|
|
@@ -980,6 +1021,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
980
1021
|
const providerOptions = {
|
|
981
1022
|
apiKey: llmKey,
|
|
982
1023
|
baseURL: llmBase,
|
|
1024
|
+
// Retry proxy may wait up to 5 min; allow long completions after upstream is healthy.
|
|
1025
|
+
timeout: 900_000,
|
|
983
1026
|
};
|
|
984
1027
|
if (sessionId) {
|
|
985
1028
|
providerOptions.headers = {
|
|
@@ -1053,6 +1096,37 @@ async function abortOpencodeSession(attachUrl, opencodeSessionId) {
|
|
|
1053
1096
|
console.error(`ChimpHands OpenCode session abort failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1054
1097
|
}
|
|
1055
1098
|
}
|
|
1099
|
+
/** Deliver a user message into an active OpenCode turn without aborting it. */
|
|
1100
|
+
async function injectOpencodeUserMessage(attachUrl, opencodeSessionId, content) {
|
|
1101
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
1102
|
+
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/prompt_async`;
|
|
1103
|
+
try {
|
|
1104
|
+
const res = await fetch(url, {
|
|
1105
|
+
method: "POST",
|
|
1106
|
+
headers: {
|
|
1107
|
+
Accept: "application/json",
|
|
1108
|
+
"Content-Type": "application/json",
|
|
1109
|
+
"x-opencode-directory": process.cwd(),
|
|
1110
|
+
},
|
|
1111
|
+
body: JSON.stringify({
|
|
1112
|
+
agent: OPENCODE_AGENT_ID,
|
|
1113
|
+
noReply: false,
|
|
1114
|
+
parts: [{ type: "text", text: content }],
|
|
1115
|
+
}),
|
|
1116
|
+
});
|
|
1117
|
+
if (res.status === 204 || res.ok) {
|
|
1118
|
+
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1119
|
+
console.error(`ChimpHands injected mid-turn user message into OpenCode session ${opencodeSessionId}: ${JSON.stringify(preview)}`);
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
console.error(`ChimpHands mid-turn inject HTTP ${res.status} for session ${opencodeSessionId}`);
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
catch (err) {
|
|
1126
|
+
console.error(`ChimpHands mid-turn inject failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1056
1130
|
function parseInboundUserMessage(data) {
|
|
1057
1131
|
if (!data || typeof data !== "object")
|
|
1058
1132
|
return null;
|
|
@@ -1732,7 +1806,16 @@ export async function runChimphands(opts) {
|
|
|
1732
1806
|
process.env.TESTCHIMP_USER_ID = userId;
|
|
1733
1807
|
}
|
|
1734
1808
|
mkdirSync(".opencode", { recursive: true });
|
|
1735
|
-
const
|
|
1809
|
+
const upstreamLlmBase = (bootStr(boot, "llm_base_url", "llmBaseUrl") || `${backend}/v1`).replace(/\/$/, "");
|
|
1810
|
+
let llmBaseForOpencode = upstreamLlmBase;
|
|
1811
|
+
let stopLlmProxy = null;
|
|
1812
|
+
if (isChimphandsLlmRetryEnabled()) {
|
|
1813
|
+
const proxy = await startLlmRetryProxy(upstreamLlmBase);
|
|
1814
|
+
llmBaseForOpencode = proxy.baseUrl;
|
|
1815
|
+
stopLlmProxy = proxy.stop;
|
|
1816
|
+
console.error(`ChimpHands LLM retry proxy: ${llmBaseForOpencode} -> ${upstreamLlmBase}`);
|
|
1817
|
+
}
|
|
1818
|
+
const opencodeModel = writeOpencodeConfig(backend, apiKey, boot, llmBaseForOpencode);
|
|
1736
1819
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
1737
1820
|
if (attachUrl) {
|
|
1738
1821
|
console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
|
|
@@ -1768,15 +1851,19 @@ export async function runChimphands(opts) {
|
|
|
1768
1851
|
const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
|
|
1769
1852
|
const queue = [];
|
|
1770
1853
|
const seenUserMessageIds = new Set();
|
|
1854
|
+
/** Mid-turn prompt_async successes that may need a follow-up turn if OpenCode never answered. */
|
|
1855
|
+
const midTurnInjectedById = new Map();
|
|
1856
|
+
/** Steering keys answered by the parallel child-session sidecar. */
|
|
1857
|
+
const midTurnSidecarAnswered = new Set();
|
|
1771
1858
|
let idle = false;
|
|
1772
1859
|
let sessionActive = true;
|
|
1773
1860
|
let lastUserActivity = Date.now();
|
|
1774
1861
|
let exitCode;
|
|
1775
1862
|
let cancelTurnRequested = false;
|
|
1776
|
-
/** True when cancel was triggered by a mid-turn user message (steer), not Stop. */
|
|
1777
|
-
let steerAbortRequested = false;
|
|
1778
1863
|
let activeOpencodeChild = null;
|
|
1779
1864
|
let agentTurnInProgress = false;
|
|
1865
|
+
/** Mid-turn messages waiting for an OpenCode session id before prompt_async inject. */
|
|
1866
|
+
const pendingMidTurnInject = [];
|
|
1780
1867
|
/** Wakes waitForNextPrompt when idle is signaled (SSE) or a user message arrives. */
|
|
1781
1868
|
let wakeWaitForPrompt = null;
|
|
1782
1869
|
const wakePromptWaiter = () => {
|
|
@@ -1791,11 +1878,8 @@ export async function runChimphands(opts) {
|
|
|
1791
1878
|
},
|
|
1792
1879
|
};
|
|
1793
1880
|
/** Abort in-flight OpenCode turn (session HTTP abort + kill `opencode run` child). */
|
|
1794
|
-
const abortActiveTurn = (
|
|
1881
|
+
const abortActiveTurn = () => {
|
|
1795
1882
|
cancelTurnRequested = true;
|
|
1796
|
-
if (reason === "steer") {
|
|
1797
|
-
steerAbortRequested = true;
|
|
1798
|
-
}
|
|
1799
1883
|
const sessionToAbort = opencodeSessionId?.trim();
|
|
1800
1884
|
if (attachUrl && sessionToAbort) {
|
|
1801
1885
|
void abortOpencodeSession(attachUrl, sessionToAbort);
|
|
@@ -1819,6 +1903,60 @@ export async function runChimphands(opts) {
|
|
|
1819
1903
|
}
|
|
1820
1904
|
}
|
|
1821
1905
|
};
|
|
1906
|
+
const noteMidTurnInjectSuccess = (steeringKey, content) => {
|
|
1907
|
+
midTurnInjectedById.set(steeringKey, normalizeUserMessage(content));
|
|
1908
|
+
};
|
|
1909
|
+
const deliverMidTurnSteeringSidecar = (steeringKey, content) => {
|
|
1910
|
+
const parentSessionId = opencodeSessionId?.trim();
|
|
1911
|
+
if (!attachUrl || !parentSessionId)
|
|
1912
|
+
return;
|
|
1913
|
+
void runSteeringSidecarReply({
|
|
1914
|
+
attachUrl,
|
|
1915
|
+
parentSessionId,
|
|
1916
|
+
userQuestion: content,
|
|
1917
|
+
conversationSummary,
|
|
1918
|
+
model: opencodeModel,
|
|
1919
|
+
agentId: OPENCODE_AGENT_ID,
|
|
1920
|
+
})
|
|
1921
|
+
.then((replyParts) => {
|
|
1922
|
+
if (!replyParts.length) {
|
|
1923
|
+
console.error(`ChimpHands steering sidecar returned no text for ${steeringKey}`);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
midTurnSidecarAnswered.add(steeringKey);
|
|
1927
|
+
for (const text of replyParts) {
|
|
1928
|
+
postEvent(ROLE_ASSISTANT, text, {
|
|
1929
|
+
durable: true,
|
|
1930
|
+
opencodeSessionId: parentSessionId,
|
|
1931
|
+
messageId: `ch_steering_${steeringKey}_${Date.now()}`,
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
console.error(`ChimpHands steering sidecar answered mid-turn message ${steeringKey} (${replyParts.length} part(s))`);
|
|
1935
|
+
})
|
|
1936
|
+
.catch((err) => {
|
|
1937
|
+
console.error(`ChimpHands steering sidecar failed for ${steeringKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1938
|
+
});
|
|
1939
|
+
};
|
|
1940
|
+
const flushPendingMidTurnInject = () => {
|
|
1941
|
+
if (!attachUrl || !agentTurnInProgress)
|
|
1942
|
+
return;
|
|
1943
|
+
const sessionToInject = opencodeSessionId?.trim();
|
|
1944
|
+
if (!sessionToInject)
|
|
1945
|
+
return;
|
|
1946
|
+
while (pendingMidTurnInject.length) {
|
|
1947
|
+
const content = pendingMidTurnInject.shift();
|
|
1948
|
+
const steeringKey = midTurnSteeringKey(undefined, content);
|
|
1949
|
+
void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
|
|
1950
|
+
if (!ok) {
|
|
1951
|
+
queue.push(content);
|
|
1952
|
+
wakePromptWaiter();
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
noteMidTurnInjectSuccess(steeringKey, content);
|
|
1956
|
+
deliverMidTurnSteeringSidecar(steeringKey, content);
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1822
1960
|
const enqueueUserMessage = (msg) => {
|
|
1823
1961
|
const id = msg.id?.trim();
|
|
1824
1962
|
if (id) {
|
|
@@ -1829,17 +1967,32 @@ export async function runChimphands(opts) {
|
|
|
1829
1967
|
const content = normalizeUserMessage(msg.content || "");
|
|
1830
1968
|
if (!content)
|
|
1831
1969
|
return;
|
|
1832
|
-
queue.push(content);
|
|
1833
1970
|
lastUserActivity = Date.now();
|
|
1834
1971
|
idle = false;
|
|
1835
1972
|
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1973
|
+
if (agentTurnInProgress && attachUrl) {
|
|
1974
|
+
const sessionToInject = opencodeSessionId?.trim();
|
|
1975
|
+
if (sessionToInject) {
|
|
1976
|
+
console.error(`ChimpHands delivering mid-turn user message via OpenCode prompt_async (turnActive=true): ${JSON.stringify(preview)}`);
|
|
1977
|
+
const steeringKey = midTurnSteeringKey(id, content);
|
|
1978
|
+
void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
|
|
1979
|
+
if (!ok) {
|
|
1980
|
+
queue.push(content);
|
|
1981
|
+
wakePromptWaiter();
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
noteMidTurnInjectSuccess(steeringKey, content);
|
|
1985
|
+
postEvent(ROLE_STATUS, MID_TURN_DELIVERED_STATUS, { status: STATUS_RUNNING });
|
|
1986
|
+
deliverMidTurnSteeringSidecar(steeringKey, content);
|
|
1987
|
+
});
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
pendingMidTurnInject.push(content);
|
|
1991
|
+
console.error(`ChimpHands holding mid-turn user message until OpenCode session id is available: ${JSON.stringify(preview)}`);
|
|
1992
|
+
return;
|
|
1842
1993
|
}
|
|
1994
|
+
queue.push(content);
|
|
1995
|
+
console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
|
|
1843
1996
|
wakePromptWaiter();
|
|
1844
1997
|
};
|
|
1845
1998
|
const pollPendingUserMessages = async () => {
|
|
@@ -1906,8 +2059,7 @@ export async function runChimphands(opts) {
|
|
|
1906
2059
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
1907
2060
|
const handleCancelTurn = () => {
|
|
1908
2061
|
console.error(`ChimpHands cancel_turn received for session ${sessionId}`);
|
|
1909
|
-
|
|
1910
|
-
abortActiveTurn("cancel");
|
|
2062
|
+
abortActiveTurn();
|
|
1911
2063
|
};
|
|
1912
2064
|
const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
|
|
1913
2065
|
onUserMessage: enqueueUserMessage,
|
|
@@ -1944,6 +2096,12 @@ export async function runChimphands(opts) {
|
|
|
1944
2096
|
wakePromptWaiter();
|
|
1945
2097
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1946
2098
|
await poster.flush();
|
|
2099
|
+
if (stopLlmProxy) {
|
|
2100
|
+
await stopLlmProxy().catch((err) => {
|
|
2101
|
+
console.error(`ChimpHands LLM retry proxy stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2102
|
+
});
|
|
2103
|
+
stopLlmProxy = null;
|
|
2104
|
+
}
|
|
1947
2105
|
if (runtimeId) {
|
|
1948
2106
|
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
1949
2107
|
runtimeId,
|
|
@@ -2022,21 +2180,12 @@ export async function runChimphands(opts) {
|
|
|
2022
2180
|
});
|
|
2023
2181
|
while (prompt) {
|
|
2024
2182
|
cancelTurnRequested = false;
|
|
2025
|
-
const wasSteered = steerAbortRequested;
|
|
2026
|
-
steerAbortRequested = false;
|
|
2027
2183
|
agentTurnInProgress = true;
|
|
2184
|
+
flushPendingMidTurnInject();
|
|
2028
2185
|
let useOpencodeSessionId = opencodeSessionId;
|
|
2029
2186
|
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);
|
|
2187
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
2188
|
+
const turnPostEvent = (role, content, opts) => postEventForTurn(prompt, effectivePrompt, role, content, opts);
|
|
2040
2189
|
// Visible in chat (not filtered as routine). OpenCode may not emit text until a
|
|
2041
2190
|
// part completes — without this the UI looks empty while the turn is running.
|
|
2042
2191
|
postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
|
|
@@ -2047,6 +2196,7 @@ export async function runChimphands(opts) {
|
|
|
2047
2196
|
sessionId,
|
|
2048
2197
|
opencodeSessionId: id,
|
|
2049
2198
|
}).catch(() => { });
|
|
2199
|
+
flushPendingMidTurnInject();
|
|
2050
2200
|
},
|
|
2051
2201
|
onWorkingBranch: noteWorkingBranch,
|
|
2052
2202
|
postEvent: turnPostEvent,
|
|
@@ -2066,6 +2216,7 @@ export async function runChimphands(opts) {
|
|
|
2066
2216
|
sessionId,
|
|
2067
2217
|
opencodeSessionId: id,
|
|
2068
2218
|
}).catch(() => { });
|
|
2219
|
+
flushPendingMidTurnInject();
|
|
2069
2220
|
},
|
|
2070
2221
|
onWorkingBranch: noteWorkingBranch,
|
|
2071
2222
|
postEvent: turnPostEvent,
|
|
@@ -2073,6 +2224,9 @@ export async function runChimphands(opts) {
|
|
|
2073
2224
|
}, attachUrl);
|
|
2074
2225
|
}
|
|
2075
2226
|
agentTurnInProgress = false;
|
|
2227
|
+
while (pendingMidTurnInject.length) {
|
|
2228
|
+
queue.push(pendingMidTurnInject.shift());
|
|
2229
|
+
}
|
|
2076
2230
|
await poster.flush();
|
|
2077
2231
|
if (result.opencodeSessionId) {
|
|
2078
2232
|
opencodeSessionId = result.opencodeSessionId;
|
|
@@ -2100,6 +2254,14 @@ export async function runChimphands(opts) {
|
|
|
2100
2254
|
if (!poster.uiAttached) {
|
|
2101
2255
|
await commitAndPushDirtyWorktree("chimphands: commit after turn (async / no UI attached)");
|
|
2102
2256
|
}
|
|
2257
|
+
if (attachUrl && opencodeSessionId && midTurnInjectedById.size) {
|
|
2258
|
+
try {
|
|
2259
|
+
await releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjectedById, queue, wakePromptWaiter, midTurnSidecarAnswered);
|
|
2260
|
+
}
|
|
2261
|
+
catch (err) {
|
|
2262
|
+
console.error(`ChimpHands mid-turn steering release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2103
2265
|
if (result.code !== 0 && !result.cancelled) {
|
|
2104
2266
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
2105
2267
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
@@ -2124,11 +2286,7 @@ export async function runChimphands(opts) {
|
|
|
2124
2286
|
exitCode = result.code || 1;
|
|
2125
2287
|
break;
|
|
2126
2288
|
}
|
|
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) {
|
|
2289
|
+
if (result.cancelled) {
|
|
2132
2290
|
postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
|
|
2133
2291
|
}
|
|
2134
2292
|
else {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Parallel OpenCode child-session turn for mid-turn steering Q&A. */
|
|
2
|
+
export type OpencodeModelRef = {
|
|
3
|
+
providerID: string;
|
|
4
|
+
modelID: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function parseOpencodeModelRef(model: string): OpencodeModelRef | undefined;
|
|
7
|
+
export declare function buildSteeringSidecarPrompt(userQuestion: string, conversationSummary: string): string;
|
|
8
|
+
type OpencodeMessagePart = {
|
|
9
|
+
type?: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
state?: {
|
|
12
|
+
status?: string;
|
|
13
|
+
output?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
type OpencodeMessageResponse = {
|
|
17
|
+
info?: {
|
|
18
|
+
role?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
};
|
|
21
|
+
parts?: OpencodeMessagePart[];
|
|
22
|
+
};
|
|
23
|
+
export declare function extractAssistantReplyParts(response: OpencodeMessageResponse): string[];
|
|
24
|
+
export type SteeringSidecarOptions = {
|
|
25
|
+
attachUrl: string;
|
|
26
|
+
parentSessionId: string;
|
|
27
|
+
userQuestion: string;
|
|
28
|
+
conversationSummary?: string;
|
|
29
|
+
model: string;
|
|
30
|
+
agentId: string;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
/** Max wait for the child-session /message round-trip. */
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Run a parallel OpenCode turn on a child session so the user gets an immediate
|
|
37
|
+
* answer while the main session stays blocked on a long-running tool.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runSteeringSidecarReply(opts: SteeringSidecarOptions): Promise<string[]>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Parallel OpenCode child-session turn for mid-turn steering Q&A. */
|
|
2
|
+
export function parseOpencodeModelRef(model) {
|
|
3
|
+
const trimmed = model.trim();
|
|
4
|
+
if (!trimmed)
|
|
5
|
+
return undefined;
|
|
6
|
+
const slash = trimmed.indexOf("/");
|
|
7
|
+
if (slash <= 0 || slash >= trimmed.length - 1)
|
|
8
|
+
return undefined;
|
|
9
|
+
return {
|
|
10
|
+
providerID: trimmed.slice(0, slash),
|
|
11
|
+
modelID: trimmed.slice(slash + 1),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function buildSteeringSidecarPrompt(userQuestion, conversationSummary) {
|
|
15
|
+
const summary = conversationSummary.trim();
|
|
16
|
+
const parts = [
|
|
17
|
+
"Mid-turn steering reply — the main ChimpHands session is still running a long command in parallel.",
|
|
18
|
+
"Answer the user's question directly and concisely.",
|
|
19
|
+
"Do NOT run bash/shell, edit files, or kick off long tools. Read-only lookup is OK if needed.",
|
|
20
|
+
"Do not repeat the full plan; focus on the question.",
|
|
21
|
+
];
|
|
22
|
+
if (summary) {
|
|
23
|
+
parts.push("", "Conversation so far:", summary);
|
|
24
|
+
}
|
|
25
|
+
parts.push("", "User question:", userQuestion.trim());
|
|
26
|
+
return parts.join("\n");
|
|
27
|
+
}
|
|
28
|
+
export function extractAssistantReplyParts(response) {
|
|
29
|
+
const role = (response.info?.role || "").toLowerCase();
|
|
30
|
+
if (role && role !== "assistant")
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const part of response.parts || []) {
|
|
34
|
+
if (part.type === "text" && part.text?.trim()) {
|
|
35
|
+
out.push(part.text.trim());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
async function opencodeFetch(attachUrl, path, init, cwd) {
|
|
41
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
42
|
+
return fetch(`${base}${path}`, {
|
|
43
|
+
...init,
|
|
44
|
+
headers: {
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"x-opencode-directory": cwd,
|
|
48
|
+
...init.headers,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function createSteeringChildSession(attachUrl, parentSessionId, cwd) {
|
|
53
|
+
const res = await opencodeFetch(attachUrl, "/session", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
parentID: parentSessionId,
|
|
57
|
+
title: "ChimpHands steering",
|
|
58
|
+
}),
|
|
59
|
+
}, cwd);
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`OpenCode create steering session HTTP ${res.status}`);
|
|
62
|
+
}
|
|
63
|
+
const data = (await res.json());
|
|
64
|
+
const childId = data.id?.trim();
|
|
65
|
+
if (!childId)
|
|
66
|
+
throw new Error("OpenCode create steering session returned no id");
|
|
67
|
+
return childId;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Run a parallel OpenCode turn on a child session so the user gets an immediate
|
|
71
|
+
* answer while the main session stays blocked on a long-running tool.
|
|
72
|
+
*/
|
|
73
|
+
export async function runSteeringSidecarReply(opts) {
|
|
74
|
+
const cwd = opts.cwd || process.cwd();
|
|
75
|
+
const childSessionId = await createSteeringChildSession(opts.attachUrl, opts.parentSessionId, cwd);
|
|
76
|
+
const modelRef = parseOpencodeModelRef(opts.model);
|
|
77
|
+
const prompt = buildSteeringSidecarPrompt(opts.userQuestion, opts.conversationSummary || "");
|
|
78
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
79
|
+
const ac = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
81
|
+
try {
|
|
82
|
+
const body = {
|
|
83
|
+
agent: opts.agentId,
|
|
84
|
+
noReply: false,
|
|
85
|
+
parts: [{ type: "text", text: prompt }],
|
|
86
|
+
};
|
|
87
|
+
if (modelRef)
|
|
88
|
+
body.model = modelRef;
|
|
89
|
+
const res = await opencodeFetch(opts.attachUrl, `/session/${encodeURIComponent(childSessionId)}/message`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: JSON.stringify(body),
|
|
92
|
+
signal: ac.signal,
|
|
93
|
+
}, cwd);
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
throw new Error(`OpenCode steering /message HTTP ${res.status}`);
|
|
96
|
+
}
|
|
97
|
+
const data = (await res.json());
|
|
98
|
+
return extractAssistantReplyParts(data);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { buildSteeringSidecarPrompt, extractAssistantReplyParts, parseOpencodeModelRef, } from "./steeringSidecar.js";
|
|
4
|
+
describe("parseOpencodeModelRef", () => {
|
|
5
|
+
it("splits provider/model", () => {
|
|
6
|
+
assert.deepEqual(parseOpencodeModelRef("testchimp/gpt-5.6-luna"), {
|
|
7
|
+
providerID: "testchimp",
|
|
8
|
+
modelID: "gpt-5.6-luna",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe("buildSteeringSidecarPrompt", () => {
|
|
13
|
+
it("includes summary and user question", () => {
|
|
14
|
+
const prompt = buildSteeringSidecarPrompt("why WIF?", "Prior: discussed CI auth.");
|
|
15
|
+
assert.match(prompt, /why WIF\?/);
|
|
16
|
+
assert.match(prompt, /Prior: discussed CI auth\./);
|
|
17
|
+
assert.match(prompt, /Do NOT run bash/);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("extractAssistantReplyParts", () => {
|
|
21
|
+
it("returns text parts from assistant message", () => {
|
|
22
|
+
const parts = extractAssistantReplyParts({
|
|
23
|
+
info: { role: "assistant" },
|
|
24
|
+
parts: [{ type: "text", text: "Use WIF in CI." }],
|
|
25
|
+
});
|
|
26
|
+
assert.deepEqual(parts, ["Use WIF in CI."]);
|
|
27
|
+
});
|
|
28
|
+
});
|
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.68",
|
|
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 dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js",
|
|
16
17
|
"watch": "tsc --watch",
|
|
17
18
|
"start": "node dist/bin/testchimp.js",
|
|
18
19
|
"prepublishOnly": "npm run build"
|