@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.
@@ -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
+ });
@@ -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 = (bootStr(boot, "llm_base_url", "llmBaseUrl") || `${backend}/v1`).replace(/\/$/, "");
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 opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
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 = (reason) => {
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
- console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
1837
- // OpenCode has no mid-turn inject: abort the active turn so the queued
1838
- // message is consumed immediately as the next prompt (course-correct).
1839
- if (agentTurnInProgress && !cancelTurnRequested) {
1840
- console.error(`ChimpHands steering: aborting active turn so queued user message reaches OpenCode`);
1841
- abortActiveTurn("steer");
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
- steerAbortRequested = false;
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
- const promptForOpencode = wasSteered
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 && (steerAbortRequested || queue.length > 0)) {
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 {
@@ -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)
@@ -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>;
@@ -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
+ });
@@ -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.66",
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"