@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.
- 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 +98 -5
- 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
|
});
|
|
@@ -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
|
-
|
|
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.
|
|
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"
|