@testchimp/cli 0.1.67 → 0.1.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chimphands/midTurnSteering.d.ts +25 -0
- package/dist/chimphands/midTurnSteering.js +93 -0
- package/dist/chimphands/midTurnSteering.test.d.ts +1 -0
- package/dist/chimphands/midTurnSteering.test.js +46 -0
- package/dist/chimphands/run.js +130 -22
- package/dist/chimphands/steeringSidecar.d.ts +40 -0
- package/dist/chimphands/steeringSidecar.js +103 -0
- package/dist/chimphands/steeringSidecar.test.d.ts +1 -0
- package/dist/chimphands/steeringSidecar.test.js +28 -0
- package/package.json +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Helpers for mid-turn user steering via OpenCode prompt_async. */
|
|
2
|
+
export type OpencodeSessionMessage = {
|
|
3
|
+
info?: {
|
|
4
|
+
id?: string;
|
|
5
|
+
role?: string;
|
|
6
|
+
};
|
|
7
|
+
parts?: Array<{
|
|
8
|
+
type?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
state?: {
|
|
11
|
+
status?: string;
|
|
12
|
+
};
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
export declare function normalizeSteeringContent(content: string): string;
|
|
16
|
+
export declare function midTurnSteeringKey(id: string | undefined, content: string): string;
|
|
17
|
+
export declare function extractUserMessageText(msg: OpencodeSessionMessage): string;
|
|
18
|
+
/** True when an assistant message after the matching user text has substantive output. */
|
|
19
|
+
export declare function userMessageHasAssistantReply(messages: OpencodeSessionMessage[], userContent: string): boolean;
|
|
20
|
+
export declare function fetchOpencodeSessionMessages(attachUrl: string, opencodeSessionId: string, cwd?: string): Promise<OpencodeSessionMessage[]>;
|
|
21
|
+
/**
|
|
22
|
+
* After a turn ends, re-queue mid-turn steering messages that prompt_async accepted
|
|
23
|
+
* but OpenCode never answered (e.g. blocked on long-running bash).
|
|
24
|
+
*/
|
|
25
|
+
export declare function releaseUnansweredMidTurnMessages(attachUrl: string, opencodeSessionId: string, midTurnInjected: Map<string, string>, queue: string[], wake: () => void, sidecarAnsweredKeys?: Set<string>): Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Helpers for mid-turn user steering via OpenCode prompt_async. */
|
|
2
|
+
export function normalizeSteeringContent(content) {
|
|
3
|
+
return content.trim();
|
|
4
|
+
}
|
|
5
|
+
export function midTurnSteeringKey(id, content) {
|
|
6
|
+
const norm = normalizeSteeringContent(content);
|
|
7
|
+
return id?.trim() || `content:${norm}`;
|
|
8
|
+
}
|
|
9
|
+
export function extractUserMessageText(msg) {
|
|
10
|
+
return (msg.parts || [])
|
|
11
|
+
.filter((p) => p.type === "text" && p.text?.trim())
|
|
12
|
+
.map((p) => p.text.trim())
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
15
|
+
/** True when an assistant message after the matching user text has substantive output. */
|
|
16
|
+
export function userMessageHasAssistantReply(messages, userContent) {
|
|
17
|
+
const normalizedTarget = normalizeSteeringContent(userContent);
|
|
18
|
+
if (!normalizedTarget)
|
|
19
|
+
return true;
|
|
20
|
+
let userIdx = -1;
|
|
21
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
22
|
+
const role = (messages[i]?.info?.role || "").toLowerCase();
|
|
23
|
+
if (role !== "user")
|
|
24
|
+
continue;
|
|
25
|
+
const text = normalizeSteeringContent(extractUserMessageText(messages[i]));
|
|
26
|
+
if (text === normalizedTarget) {
|
|
27
|
+
userIdx = i;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (userIdx < 0)
|
|
32
|
+
return false;
|
|
33
|
+
for (let i = userIdx + 1; i < messages.length; i++) {
|
|
34
|
+
const role = (messages[i]?.info?.role || "").toLowerCase();
|
|
35
|
+
if (role !== "assistant")
|
|
36
|
+
continue;
|
|
37
|
+
const hasText = (messages[i]?.parts || []).some((p) => (p.type === "text" || p.type === "reasoning") && p.text?.trim());
|
|
38
|
+
const hasCompletedTool = (messages[i]?.parts || []).some((p) => p.type === "tool" && p.state?.status === "completed");
|
|
39
|
+
if (hasText || hasCompletedTool)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export async function fetchOpencodeSessionMessages(attachUrl, opencodeSessionId, cwd = process.cwd()) {
|
|
45
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
46
|
+
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
headers: { Accept: "application/json", "x-opencode-directory": cwd },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`OpenCode session messages HTTP ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
const data = (await res.json());
|
|
54
|
+
return Array.isArray(data) ? data : [];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* After a turn ends, re-queue mid-turn steering messages that prompt_async accepted
|
|
58
|
+
* but OpenCode never answered (e.g. blocked on long-running bash).
|
|
59
|
+
*/
|
|
60
|
+
export async function releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjected, queue, wake, sidecarAnsweredKeys = new Set()) {
|
|
61
|
+
if (!midTurnInjected.size)
|
|
62
|
+
return;
|
|
63
|
+
let messages = [];
|
|
64
|
+
try {
|
|
65
|
+
messages = await fetchOpencodeSessionMessages(attachUrl, opencodeSessionId);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`ChimpHands mid-turn steering check failed — queueing unanswered injected messages: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
for (const [key, content] of midTurnInjected.entries()) {
|
|
70
|
+
if (sidecarAnsweredKeys.has(key))
|
|
71
|
+
continue;
|
|
72
|
+
queue.push(content);
|
|
73
|
+
}
|
|
74
|
+
midTurnInjected.clear();
|
|
75
|
+
wake();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const [key, content] of midTurnInjected.entries()) {
|
|
79
|
+
if (sidecarAnsweredKeys.has(key)) {
|
|
80
|
+
console.error(`ChimpHands mid-turn steering message ${key} answered by sidecar — no re-queue`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (userMessageHasAssistantReply(messages, content)) {
|
|
84
|
+
console.error(`ChimpHands mid-turn steering message ${key} was answered in-session — no re-queue`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
console.error(`ChimpHands mid-turn steering message ${key} unanswered — queueing for next turn`);
|
|
88
|
+
queue.push(content);
|
|
89
|
+
}
|
|
90
|
+
midTurnInjected.clear();
|
|
91
|
+
if (queue.length)
|
|
92
|
+
wake();
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { userMessageHasAssistantReply, } from "./midTurnSteering.js";
|
|
4
|
+
function userMsg(text) {
|
|
5
|
+
return { info: { role: "user" }, parts: [{ type: "text", text }] };
|
|
6
|
+
}
|
|
7
|
+
function assistantText(text) {
|
|
8
|
+
return { info: { role: "assistant" }, parts: [{ type: "text", text }] };
|
|
9
|
+
}
|
|
10
|
+
function assistantTool(status) {
|
|
11
|
+
return {
|
|
12
|
+
info: { role: "assistant" },
|
|
13
|
+
parts: [{ type: "tool", state: { status } }],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe("userMessageHasAssistantReply", () => {
|
|
17
|
+
it("returns false when user message has no assistant reply after it", () => {
|
|
18
|
+
const messages = [userMsg("why gcp_sa_json_path?")];
|
|
19
|
+
assert.equal(userMessageHasAssistantReply(messages, "why gcp_sa_json_path?"), false);
|
|
20
|
+
});
|
|
21
|
+
it("returns true when assistant text follows the matching user message", () => {
|
|
22
|
+
const messages = [
|
|
23
|
+
userMsg("first question"),
|
|
24
|
+
assistantText("first answer"),
|
|
25
|
+
userMsg("why gcp_sa_json_path?"),
|
|
26
|
+
assistantText("We should use WIF in CI."),
|
|
27
|
+
];
|
|
28
|
+
assert.equal(userMessageHasAssistantReply(messages, "why gcp_sa_json_path?"), true);
|
|
29
|
+
});
|
|
30
|
+
it("matches the latest user message with the same content", () => {
|
|
31
|
+
const messages = [
|
|
32
|
+
userMsg("repeat"),
|
|
33
|
+
assistantText("old answer"),
|
|
34
|
+
userMsg("repeat"),
|
|
35
|
+
];
|
|
36
|
+
assert.equal(userMessageHasAssistantReply(messages, "repeat"), false);
|
|
37
|
+
});
|
|
38
|
+
it("counts completed tool output as a reply", () => {
|
|
39
|
+
const messages = [userMsg("check auth"), assistantTool("completed")];
|
|
40
|
+
assert.equal(userMessageHasAssistantReply(messages, "check auth"), true);
|
|
41
|
+
});
|
|
42
|
+
it("ignores pending tool parts as a reply", () => {
|
|
43
|
+
const messages = [userMsg("run tests"), assistantTool("running")];
|
|
44
|
+
assert.equal(userMessageHasAssistantReply(messages, "run tests"), false);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/dist/chimphands/run.js
CHANGED
|
@@ -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 (
|
|
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 =
|
|
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
|
});
|
|
@@ -1497,6 +1537,11 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
|
|
|
1497
1537
|
}
|
|
1498
1538
|
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
1499
1539
|
await fs.writeFile(full, edit.content, "utf8");
|
|
1540
|
+
const pushed = await commitAndPushDirtyWorktree("User Update");
|
|
1541
|
+
if (!pushed.ok) {
|
|
1542
|
+
await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1500
1545
|
await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
|
|
1501
1546
|
}
|
|
1502
1547
|
catch (err) {
|
|
@@ -1811,6 +1856,10 @@ export async function runChimphands(opts) {
|
|
|
1811
1856
|
const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
|
|
1812
1857
|
const queue = [];
|
|
1813
1858
|
const seenUserMessageIds = new Set();
|
|
1859
|
+
/** Mid-turn prompt_async successes that may need a follow-up turn if OpenCode never answered. */
|
|
1860
|
+
const midTurnInjectedById = new Map();
|
|
1861
|
+
/** Steering keys answered by the parallel child-session sidecar. */
|
|
1862
|
+
const midTurnSidecarAnswered = new Set();
|
|
1814
1863
|
let idle = false;
|
|
1815
1864
|
let sessionActive = true;
|
|
1816
1865
|
let lastUserActivity = Date.now();
|
|
@@ -1859,6 +1908,40 @@ export async function runChimphands(opts) {
|
|
|
1859
1908
|
}
|
|
1860
1909
|
}
|
|
1861
1910
|
};
|
|
1911
|
+
const noteMidTurnInjectSuccess = (steeringKey, content) => {
|
|
1912
|
+
midTurnInjectedById.set(steeringKey, normalizeUserMessage(content));
|
|
1913
|
+
};
|
|
1914
|
+
const deliverMidTurnSteeringSidecar = (steeringKey, content) => {
|
|
1915
|
+
const parentSessionId = opencodeSessionId?.trim();
|
|
1916
|
+
if (!attachUrl || !parentSessionId)
|
|
1917
|
+
return;
|
|
1918
|
+
void runSteeringSidecarReply({
|
|
1919
|
+
attachUrl,
|
|
1920
|
+
parentSessionId,
|
|
1921
|
+
userQuestion: content,
|
|
1922
|
+
conversationSummary,
|
|
1923
|
+
model: opencodeModel,
|
|
1924
|
+
agentId: OPENCODE_AGENT_ID,
|
|
1925
|
+
})
|
|
1926
|
+
.then((replyParts) => {
|
|
1927
|
+
if (!replyParts.length) {
|
|
1928
|
+
console.error(`ChimpHands steering sidecar returned no text for ${steeringKey}`);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
midTurnSidecarAnswered.add(steeringKey);
|
|
1932
|
+
for (const text of replyParts) {
|
|
1933
|
+
postEvent(ROLE_ASSISTANT, text, {
|
|
1934
|
+
durable: true,
|
|
1935
|
+
opencodeSessionId: parentSessionId,
|
|
1936
|
+
messageId: `ch_steering_${steeringKey}_${Date.now()}`,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
console.error(`ChimpHands steering sidecar answered mid-turn message ${steeringKey} (${replyParts.length} part(s))`);
|
|
1940
|
+
})
|
|
1941
|
+
.catch((err) => {
|
|
1942
|
+
console.error(`ChimpHands steering sidecar failed for ${steeringKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1943
|
+
});
|
|
1944
|
+
};
|
|
1862
1945
|
const flushPendingMidTurnInject = () => {
|
|
1863
1946
|
if (!attachUrl || !agentTurnInProgress)
|
|
1864
1947
|
return;
|
|
@@ -1867,11 +1950,15 @@ export async function runChimphands(opts) {
|
|
|
1867
1950
|
return;
|
|
1868
1951
|
while (pendingMidTurnInject.length) {
|
|
1869
1952
|
const content = pendingMidTurnInject.shift();
|
|
1953
|
+
const steeringKey = midTurnSteeringKey(undefined, content);
|
|
1870
1954
|
void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
|
|
1871
1955
|
if (!ok) {
|
|
1872
1956
|
queue.push(content);
|
|
1873
1957
|
wakePromptWaiter();
|
|
1958
|
+
return;
|
|
1874
1959
|
}
|
|
1960
|
+
noteMidTurnInjectSuccess(steeringKey, content);
|
|
1961
|
+
deliverMidTurnSteeringSidecar(steeringKey, content);
|
|
1875
1962
|
});
|
|
1876
1963
|
}
|
|
1877
1964
|
};
|
|
@@ -1892,13 +1979,16 @@ export async function runChimphands(opts) {
|
|
|
1892
1979
|
const sessionToInject = opencodeSessionId?.trim();
|
|
1893
1980
|
if (sessionToInject) {
|
|
1894
1981
|
console.error(`ChimpHands delivering mid-turn user message via OpenCode prompt_async (turnActive=true): ${JSON.stringify(preview)}`);
|
|
1982
|
+
const steeringKey = midTurnSteeringKey(id, content);
|
|
1895
1983
|
void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
|
|
1896
1984
|
if (!ok) {
|
|
1897
1985
|
queue.push(content);
|
|
1898
1986
|
wakePromptWaiter();
|
|
1899
1987
|
return;
|
|
1900
1988
|
}
|
|
1901
|
-
|
|
1989
|
+
noteMidTurnInjectSuccess(steeringKey, content);
|
|
1990
|
+
postEvent(ROLE_STATUS, MID_TURN_DELIVERED_STATUS, { status: STATUS_RUNNING });
|
|
1991
|
+
deliverMidTurnSteeringSidecar(steeringKey, content);
|
|
1902
1992
|
});
|
|
1903
1993
|
return;
|
|
1904
1994
|
}
|
|
@@ -2169,6 +2259,14 @@ export async function runChimphands(opts) {
|
|
|
2169
2259
|
if (!poster.uiAttached) {
|
|
2170
2260
|
await commitAndPushDirtyWorktree("chimphands: commit after turn (async / no UI attached)");
|
|
2171
2261
|
}
|
|
2262
|
+
if (attachUrl && opencodeSessionId && midTurnInjectedById.size) {
|
|
2263
|
+
try {
|
|
2264
|
+
await releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjectedById, queue, wakePromptWaiter, midTurnSidecarAnswered);
|
|
2265
|
+
}
|
|
2266
|
+
catch (err) {
|
|
2267
|
+
console.error(`ChimpHands mid-turn steering release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2172
2270
|
if (result.code !== 0 && !result.cancelled) {
|
|
2173
2271
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
2174
2272
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
@@ -2272,26 +2370,30 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2272
2370
|
try {
|
|
2273
2371
|
const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2274
2372
|
if (branch.code !== 0) {
|
|
2275
|
-
|
|
2276
|
-
|
|
2373
|
+
const error = branch.err || branch.out || "git rev-parse failed";
|
|
2374
|
+
console.error(`ChimpHands git rev-parse failed: ${error}`);
|
|
2375
|
+
return { ok: false, error };
|
|
2277
2376
|
}
|
|
2278
2377
|
const current = branch.out.trim();
|
|
2279
2378
|
if (!current || current === "HEAD" || /^(main|master)$/i.test(current)) {
|
|
2280
|
-
|
|
2281
|
-
|
|
2379
|
+
const error = `refusing branch "${current || "(unknown)"}"`;
|
|
2380
|
+
console.error(`ChimpHands skip commit-before-idle: ${error}`);
|
|
2381
|
+
return { ok: false, error };
|
|
2282
2382
|
}
|
|
2283
2383
|
const status = await run(["status", "--porcelain"]);
|
|
2284
2384
|
if (status.code !== 0) {
|
|
2285
|
-
|
|
2286
|
-
|
|
2385
|
+
const error = status.err || status.out || "git status failed";
|
|
2386
|
+
console.error(`ChimpHands git status failed: ${error}`);
|
|
2387
|
+
return { ok: false, error };
|
|
2287
2388
|
}
|
|
2288
2389
|
if (!status.out.trim()) {
|
|
2289
|
-
return;
|
|
2390
|
+
return { ok: true };
|
|
2290
2391
|
}
|
|
2291
2392
|
const add = await run(["add", "-A"]);
|
|
2292
2393
|
if (add.code !== 0) {
|
|
2293
|
-
|
|
2294
|
-
|
|
2394
|
+
const error = add.err || add.out || "git add failed";
|
|
2395
|
+
console.error(`ChimpHands git add failed: ${error}`);
|
|
2396
|
+
return { ok: false, error };
|
|
2295
2397
|
}
|
|
2296
2398
|
const commitEnv = {
|
|
2297
2399
|
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "ChimpHands",
|
|
@@ -2301,8 +2403,9 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2301
2403
|
};
|
|
2302
2404
|
const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
|
|
2303
2405
|
if (commit.code !== 0) {
|
|
2304
|
-
|
|
2305
|
-
|
|
2406
|
+
const error = commit.err || commit.out || "git commit failed";
|
|
2407
|
+
console.error(`ChimpHands git commit: ${error}`);
|
|
2408
|
+
return { ok: false, error };
|
|
2306
2409
|
}
|
|
2307
2410
|
let push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2308
2411
|
if (push.code !== 0) {
|
|
@@ -2315,23 +2418,28 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2315
2418
|
await refreshGitAuth();
|
|
2316
2419
|
push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2317
2420
|
if (push.code !== 0) {
|
|
2318
|
-
|
|
2319
|
-
|
|
2421
|
+
const error = push.err || push.out || "git push failed after refresh";
|
|
2422
|
+
console.error(`ChimpHands git push failed after refresh: ${error}`);
|
|
2423
|
+
return { ok: false, error };
|
|
2320
2424
|
}
|
|
2321
2425
|
}
|
|
2322
2426
|
else {
|
|
2323
|
-
return;
|
|
2427
|
+
return { ok: false, error: push.err || push.out || "git push failed" };
|
|
2324
2428
|
}
|
|
2325
2429
|
}
|
|
2326
2430
|
catch (refreshErr) {
|
|
2327
|
-
|
|
2328
|
-
|
|
2431
|
+
const error = refreshErr instanceof Error ? refreshErr.message : String(refreshErr);
|
|
2432
|
+
console.error(`ChimpHands refresh-git-auth after push failure: ${error}`);
|
|
2433
|
+
return { ok: false, error };
|
|
2329
2434
|
}
|
|
2330
2435
|
}
|
|
2331
2436
|
console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
|
|
2437
|
+
return { ok: true };
|
|
2332
2438
|
}
|
|
2333
2439
|
catch (err) {
|
|
2334
|
-
|
|
2440
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2441
|
+
console.error(`ChimpHands commit-before-idle failed: ${error}`);
|
|
2442
|
+
return { ok: false, error };
|
|
2335
2443
|
}
|
|
2336
2444
|
}
|
|
2337
2445
|
function opencodeProxyNeedsDirectory(path) {
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.69",
|
|
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"
|