@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.
@@ -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
+ });
@@ -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 (!input)
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 = input.timeout ?? input.timeoutMs ?? input.timeout_ms;
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 = (bootStr(boot, "llm_base_url", "llmBaseUrl") || `${backend}/v1`).replace(/\/$/, "");
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 opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
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 = (reason) => {
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
- 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");
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
- steerAbortRequested = false;
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
- 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);
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 && (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) {
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
+ });
@@ -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.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"