@testchimp/cli 0.1.67 → 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,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
+ });
@@ -12,6 +12,8 @@ import https from "node:https";
12
12
  import { URL } from "node:url";
13
13
  import { getBackendUrl, requireApiKey } from "../core/client.js";
14
14
  import { isChimphandsLlmRetryEnabled, startLlmRetryProxy } from "./llmRetryProxy.js";
15
+ import { midTurnSteeringKey, releaseUnansweredMidTurnMessages, } from "./midTurnSteering.js";
16
+ import { runSteeringSidecarReply } from "./steeringSidecar.js";
15
17
  const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
16
18
  const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
17
19
  const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
@@ -21,6 +23,7 @@ const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
21
23
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
22
24
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
23
25
  const OPENCODE_AGENT_ID = "chimphands";
26
+ const MID_TURN_DELIVERED_STATUS = "Answering your question while the current step continues…";
24
27
  /** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
25
28
  const STREAM_POST_MIN_INTERVAL_MS = 150;
26
29
  function isStreamFanoutRole(role) {
@@ -84,6 +87,11 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
84
87
  - Follow plan → explicit user approval → execute (interactive default). Do not skip MCP calls or claim done without tool evidence.
85
88
  - Export \`TESTCHIMP_EXECUTION_SOURCE=CLOUD_AGENT\` before Playwright/Mobilewright runs.
86
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
+
87
95
  ## Honesty
88
96
  - If MCP/tools fail, report the error. Never narrate success without tool output or a PR link when repo changes were needed.`;
89
97
  function normalizeUserMessage(content) {
@@ -180,7 +188,7 @@ class AgentEventPoster {
180
188
  if (opts?.pullRequestUrl)
181
189
  body.pullRequestUrl = opts.pullRequestUrl;
182
190
  if (opts?.toolExecution)
183
- body.toolExecution = opts.toolExecution;
191
+ body.toolExecution = protoJsonToolExecution(opts.toolExecution);
184
192
  const streamRole = isStreamFanoutRole(role);
185
193
  this.chain = this.chain.then(async () => {
186
194
  const live = !!opts?.liveStream && !opts?.durable;
@@ -207,7 +215,7 @@ class AgentEventPoster {
207
215
  if (opts?.opencodeSessionId)
208
216
  eph.opencodeSessionId = opts.opencodeSessionId;
209
217
  if (opts?.toolExecution)
210
- eph.toolExecution = opts.toolExecution;
218
+ eph.toolExecution = protoJsonToolExecution(opts.toolExecution);
211
219
  try {
212
220
  await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
213
221
  this.ephemeralFailCount = 0;
@@ -308,14 +316,44 @@ function normalizeEpochMillis(raw) {
308
316
  return raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
309
317
  }
310
318
  function coerceTimeoutMillis(input) {
311
- 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)
312
337
  return undefined;
313
- const raw = input.timeout ?? input.timeoutMs ?? input.timeout_ms;
338
+ const raw = obj.timeout ?? obj.timeoutMs ?? obj.timeout_ms;
314
339
  const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
315
340
  if (!Number.isFinite(n) || n <= 0)
316
341
  return undefined;
317
342
  return Math.round(n);
318
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
+ }
319
357
  function mapToolExecutionStatus(status) {
320
358
  const s = String(status || "").trim().toLowerCase();
321
359
  if (!s)
@@ -1071,6 +1109,8 @@ async function injectOpencodeUserMessage(attachUrl, opencodeSessionId, content)
1071
1109
  "x-opencode-directory": process.cwd(),
1072
1110
  },
1073
1111
  body: JSON.stringify({
1112
+ agent: OPENCODE_AGENT_ID,
1113
+ noReply: false,
1074
1114
  parts: [{ type: "text", text: content }],
1075
1115
  }),
1076
1116
  });
@@ -1811,6 +1851,10 @@ export async function runChimphands(opts) {
1811
1851
  const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
1812
1852
  const queue = [];
1813
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();
1814
1858
  let idle = false;
1815
1859
  let sessionActive = true;
1816
1860
  let lastUserActivity = Date.now();
@@ -1859,6 +1903,40 @@ export async function runChimphands(opts) {
1859
1903
  }
1860
1904
  }
1861
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
+ };
1862
1940
  const flushPendingMidTurnInject = () => {
1863
1941
  if (!attachUrl || !agentTurnInProgress)
1864
1942
  return;
@@ -1867,11 +1945,15 @@ export async function runChimphands(opts) {
1867
1945
  return;
1868
1946
  while (pendingMidTurnInject.length) {
1869
1947
  const content = pendingMidTurnInject.shift();
1948
+ const steeringKey = midTurnSteeringKey(undefined, content);
1870
1949
  void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
1871
1950
  if (!ok) {
1872
1951
  queue.push(content);
1873
1952
  wakePromptWaiter();
1953
+ return;
1874
1954
  }
1955
+ noteMidTurnInjectSuccess(steeringKey, content);
1956
+ deliverMidTurnSteeringSidecar(steeringKey, content);
1875
1957
  });
1876
1958
  }
1877
1959
  };
@@ -1892,13 +1974,16 @@ export async function runChimphands(opts) {
1892
1974
  const sessionToInject = opencodeSessionId?.trim();
1893
1975
  if (sessionToInject) {
1894
1976
  console.error(`ChimpHands delivering mid-turn user message via OpenCode prompt_async (turnActive=true): ${JSON.stringify(preview)}`);
1977
+ const steeringKey = midTurnSteeringKey(id, content);
1895
1978
  void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
1896
1979
  if (!ok) {
1897
1980
  queue.push(content);
1898
1981
  wakePromptWaiter();
1899
1982
  return;
1900
1983
  }
1901
- postEvent(ROLE_STATUS, "Message sent to agent…", { status: STATUS_RUNNING });
1984
+ noteMidTurnInjectSuccess(steeringKey, content);
1985
+ postEvent(ROLE_STATUS, MID_TURN_DELIVERED_STATUS, { status: STATUS_RUNNING });
1986
+ deliverMidTurnSteeringSidecar(steeringKey, content);
1902
1987
  });
1903
1988
  return;
1904
1989
  }
@@ -2169,6 +2254,14 @@ export async function runChimphands(opts) {
2169
2254
  if (!poster.uiAttached) {
2170
2255
  await commitAndPushDirtyWorktree("chimphands: commit after turn (async / no UI attached)");
2171
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
+ }
2172
2265
  if (result.code !== 0 && !result.cancelled) {
2173
2266
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
2174
2267
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.67",
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,7 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js",
16
+ "test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js",
17
17
  "watch": "tsc --watch",
18
18
  "start": "node dist/bin/testchimp.js",
19
19
  "prepublishOnly": "npm run build"