@bastani/atomic 0.5.16 → 0.5.17-0
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/.agents/skills/workflow-creator/references/agent-sessions.md +3 -1
- package/.agents/skills/workflow-creator/references/failure-modes.md +140 -0
- package/.claude/settings.json +1 -0
- package/dist/sdk/components/header.d.ts.map +1 -1
- package/dist/sdk/components/layout.d.ts.map +1 -1
- package/dist/sdk/components/node-card.d.ts.map +1 -1
- package/dist/sdk/components/orchestrator-panel-store.d.ts +2 -0
- package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -1
- package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -1
- package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -1
- package/dist/sdk/components/orchestrator-panel.d.ts +2 -0
- package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
- package/dist/sdk/components/status-helpers.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +33 -1
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts +88 -0
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts +7 -3
- package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -1
- package/dist/services/config/definitions.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/commands/cli/init/onboarding.ts +19 -2
- package/src/sdk/components/header.tsx +2 -1
- package/src/sdk/components/layout.ts +2 -1
- package/src/sdk/components/node-card.tsx +16 -0
- package/src/sdk/components/orchestrator-panel-store.test.ts +88 -0
- package/src/sdk/components/orchestrator-panel-store.ts +16 -0
- package/src/sdk/components/orchestrator-panel-types.ts +1 -1
- package/src/sdk/components/orchestrator-panel.tsx +8 -0
- package/src/sdk/components/session-graph-panel.tsx +1 -1
- package/src/sdk/components/status-helpers.ts +3 -2
- package/src/sdk/providers/claude.ts +160 -2
- package/src/sdk/runtime/executor.test.ts +144 -0
- package/src/sdk/runtime/executor.ts +219 -31
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +3 -18
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +41 -51
- package/src/services/config/definitions.ts +5 -0
|
@@ -715,6 +715,94 @@ describe("PanelStore", () => {
|
|
|
715
715
|
});
|
|
716
716
|
});
|
|
717
717
|
|
|
718
|
+
// ── awaitingInput ──────────────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
describe("awaitingInput", () => {
|
|
721
|
+
beforeEach(() => {
|
|
722
|
+
store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt");
|
|
723
|
+
store.startSession("worker");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("changes status to awaiting_input when session is running", () => {
|
|
727
|
+
store.awaitingInput("worker");
|
|
728
|
+
const s = store.sessions.find((s) => s.name === "worker")!;
|
|
729
|
+
expect(s.status).toBe("awaiting_input");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("bumps version by exactly 1", () => {
|
|
733
|
+
const before = store.version;
|
|
734
|
+
store.awaitingInput("worker");
|
|
735
|
+
expect(store.version).toBe(before + 1);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("notifies subscribed listeners", () => {
|
|
739
|
+
const listener = mock(() => {});
|
|
740
|
+
store.subscribe(listener);
|
|
741
|
+
store.awaitingInput("worker");
|
|
742
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("does not change status when session is not running (pending)", () => {
|
|
746
|
+
store.setWorkflowInfo("wf2", "claude", [{ name: "idle", parents: [] }], "prompt");
|
|
747
|
+
const before = store.version;
|
|
748
|
+
store.awaitingInput("idle");
|
|
749
|
+
const s = store.sessions.find((s) => s.name === "idle")!;
|
|
750
|
+
expect(s.status).toBe("pending");
|
|
751
|
+
expect(store.version).toBe(before);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("does not emit when session not found", () => {
|
|
755
|
+
const before = store.version;
|
|
756
|
+
store.awaitingInput("nonexistent");
|
|
757
|
+
expect(store.version).toBe(before);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// ── resumeSession ──────────────────────────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
describe("resumeSession", () => {
|
|
764
|
+
beforeEach(() => {
|
|
765
|
+
store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt");
|
|
766
|
+
store.startSession("worker");
|
|
767
|
+
store.awaitingInput("worker");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("changes status back to running when session is awaiting_input", () => {
|
|
771
|
+
store.resumeSession("worker");
|
|
772
|
+
const s = store.sessions.find((s) => s.name === "worker")!;
|
|
773
|
+
expect(s.status).toBe("running");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("bumps version by exactly 1", () => {
|
|
777
|
+
const before = store.version;
|
|
778
|
+
store.resumeSession("worker");
|
|
779
|
+
expect(store.version).toBe(before + 1);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("notifies subscribed listeners", () => {
|
|
783
|
+
const listener = mock(() => {});
|
|
784
|
+
store.subscribe(listener);
|
|
785
|
+
store.resumeSession("worker");
|
|
786
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("does not change status when session is not awaiting_input (running)", () => {
|
|
790
|
+
store.setWorkflowInfo("wf2", "claude", [{ name: "active", parents: [] }], "prompt");
|
|
791
|
+
store.startSession("active");
|
|
792
|
+
const before = store.version;
|
|
793
|
+
store.resumeSession("active");
|
|
794
|
+
const s = store.sessions.find((s) => s.name === "active")!;
|
|
795
|
+
expect(s.status).toBe("running");
|
|
796
|
+
expect(store.version).toBe(before);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("does not emit when session not found", () => {
|
|
800
|
+
const before = store.version;
|
|
801
|
+
store.resumeSession("nonexistent");
|
|
802
|
+
expect(store.version).toBe(before);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
718
806
|
// ── setViewMode ────────────────────────────────────────────────────────────
|
|
719
807
|
|
|
720
808
|
describe("setViewMode", () => {
|
|
@@ -90,6 +90,22 @@ export class PanelStore {
|
|
|
90
90
|
this.emit();
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
awaitingInput(name: string): void {
|
|
94
|
+
const session = this.sessions.find((s) => s.name === name);
|
|
95
|
+
if (session && session.status === "running") {
|
|
96
|
+
session.status = "awaiting_input";
|
|
97
|
+
this.emit();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
resumeSession(name: string): void {
|
|
102
|
+
const session = this.sessions.find((s) => s.name === name);
|
|
103
|
+
if (session && session.status === "awaiting_input") {
|
|
104
|
+
session.status = "running";
|
|
105
|
+
this.emit();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
addSession(session: SessionData): void {
|
|
94
110
|
this.sessions.push(session);
|
|
95
111
|
this.emit();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ─── Orchestrator Panel Types ─────────────────────
|
|
2
2
|
|
|
3
|
-
export type SessionStatus = "pending" | "running" | "complete" | "error";
|
|
3
|
+
export type SessionStatus = "pending" | "running" | "complete" | "error" | "awaiting_input";
|
|
4
4
|
|
|
5
5
|
export type ViewMode = "graph" | "attached";
|
|
6
6
|
|
|
@@ -111,6 +111,14 @@ export class OrchestratorPanel {
|
|
|
111
111
|
this.store.failSession(name, message);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
sessionAwaitingInput(name: string): void {
|
|
115
|
+
this.store.awaitingInput(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
sessionResumed(name: string): void {
|
|
119
|
+
this.store.resumeSession(name);
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
/** Dynamically add a new session node to the graph UI. */
|
|
115
123
|
addSession(name: string, parents: string[]): void {
|
|
116
124
|
this.store.addSession({
|
|
@@ -100,7 +100,7 @@ export function SessionGraphPanel() {
|
|
|
100
100
|
|
|
101
101
|
// Pulse animation for running nodes — paused when nothing is running
|
|
102
102
|
const hasRunning = useMemo(
|
|
103
|
-
() => store.sessions.some((s) => s.status === "running"),
|
|
103
|
+
() => store.sessions.some((s) => s.status === "running" || s.status === "awaiting_input"),
|
|
104
104
|
[storeVersion],
|
|
105
105
|
);
|
|
106
106
|
const [pulsePhase, setPulsePhase] = useState(0);
|
|
@@ -9,19 +9,20 @@ export function statusColor(status: string, theme: GraphTheme): string {
|
|
|
9
9
|
complete: theme.success,
|
|
10
10
|
pending: theme.textDim,
|
|
11
11
|
error: theme.error,
|
|
12
|
+
awaiting_input: theme.info,
|
|
12
13
|
}[status] ?? theme.textDim
|
|
13
14
|
);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function statusLabel(status: string): string {
|
|
17
18
|
return (
|
|
18
|
-
{ running: "running", complete: "done", pending: "waiting", error: "failed" }[status] ??
|
|
19
|
+
{ running: "running", complete: "done", pending: "waiting", error: "failed", awaiting_input: "input needed" }[status] ??
|
|
19
20
|
status
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function statusIcon(status: string): string {
|
|
24
|
-
return { running: "●", complete: "✓", pending: "○", error: "✗" }[status] ?? "○";
|
|
25
|
+
return { running: "●", complete: "✓", pending: "○", error: "✗", awaiting_input: "?" }[status] ?? "○";
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// ─── Duration ─────────────────────────────────────
|
|
@@ -240,6 +240,114 @@ function resolveSessionDir(cwd: string): string {
|
|
|
240
240
|
return `${home}/.claude/projects/${encodedCwd}`;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// HIL detection helpers
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Returns true if the most recent assistant message contains an
|
|
249
|
+
* `AskUserQuestion` tool_use block that has not yet been resolved
|
|
250
|
+
* by a corresponding `tool_result` in a subsequent user message.
|
|
251
|
+
*
|
|
252
|
+
* Pure function — no side effects, safe to call from a watch loop.
|
|
253
|
+
*
|
|
254
|
+
* Exported as `_hasUnresolvedHILTool` for unit testing.
|
|
255
|
+
*/
|
|
256
|
+
export function _hasUnresolvedHILTool(messages: SessionMessage[]): boolean {
|
|
257
|
+
const resolvedIds = new Set<string>();
|
|
258
|
+
|
|
259
|
+
for (const msg of messages) {
|
|
260
|
+
if (msg.type !== "user") continue;
|
|
261
|
+
const content = (msg.message as { content: unknown })?.content;
|
|
262
|
+
if (!Array.isArray(content)) continue;
|
|
263
|
+
for (const block of content) {
|
|
264
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
265
|
+
resolvedIds.add(block.tool_use_id);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const msg of [...messages].reverse()) {
|
|
271
|
+
if (msg.type !== "assistant") continue;
|
|
272
|
+
const content = (msg.message as { content: unknown })?.content;
|
|
273
|
+
if (!Array.isArray(content)) continue;
|
|
274
|
+
for (const block of content) {
|
|
275
|
+
if (
|
|
276
|
+
block.type === "tool_use" &&
|
|
277
|
+
block.name === "AskUserQuestion" &&
|
|
278
|
+
block.id &&
|
|
279
|
+
!resolvedIds.has(block.id)
|
|
280
|
+
) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Core HIL watcher loop — pure logic, dependency-injected for testability.
|
|
292
|
+
*
|
|
293
|
+
* Iterates an async iterable of "file change" events (each event triggers a
|
|
294
|
+
* transcript read via `readMessages`). Calls `onHIL(true)` when
|
|
295
|
+
* `_hasUnresolvedHILTool` first returns true, `onHIL(false)` when it returns
|
|
296
|
+
* false after having been true. The `wasHIL` guard prevents redundant
|
|
297
|
+
* callbacks on repeated events with the same HIL state. Read errors from
|
|
298
|
+
* `readMessages` are swallowed so a single corrupt JSONL write doesn't kill
|
|
299
|
+
* the watcher.
|
|
300
|
+
*
|
|
301
|
+
* Exported as `_runHILWatcher` for unit testing (event source and message
|
|
302
|
+
* reader are injected rather than hard-coded to `fs.watch` / `getSessionMessages`).
|
|
303
|
+
*/
|
|
304
|
+
export async function _runHILWatcher(
|
|
305
|
+
events: AsyncIterable<unknown>,
|
|
306
|
+
readMessages: () => Promise<SessionMessage[]>,
|
|
307
|
+
onHIL: (waiting: boolean) => void,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
let wasHIL = false;
|
|
310
|
+
|
|
311
|
+
for await (const _event of events) {
|
|
312
|
+
try {
|
|
313
|
+
const msgs = await readMessages();
|
|
314
|
+
const isHIL = _hasUnresolvedHILTool(msgs);
|
|
315
|
+
if (isHIL !== wasHIL) {
|
|
316
|
+
onHIL(isHIL);
|
|
317
|
+
wasHIL = isHIL;
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
// Transcript read failed — skip this event, try again on next write
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Watch the Claude session JSONL transcript for `AskUserQuestion` HIL events.
|
|
327
|
+
*
|
|
328
|
+
* Uses `fs/promises` `watch()` (inotify/kqueue in Bun) on the session file.
|
|
329
|
+
* On each write, re-reads messages via `getSessionMessages()` and calls
|
|
330
|
+
* `onHIL(true)` when an unresolved `AskUserQuestion` appears or
|
|
331
|
+
* `onHIL(false)` when it is resolved. Only fires on state transitions to
|
|
332
|
+
* avoid redundant callbacks.
|
|
333
|
+
*
|
|
334
|
+
* The loop exits when the provided `AbortSignal` is aborted (e.g. session
|
|
335
|
+
* becomes idle). Individual read errors are silently swallowed so a single
|
|
336
|
+
* corrupt write doesn't kill the watcher.
|
|
337
|
+
*/
|
|
338
|
+
async function watchTranscriptForHIL(
|
|
339
|
+
sessionId: string,
|
|
340
|
+
signal: AbortSignal,
|
|
341
|
+
onHIL: (waiting: boolean) => void,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
const jsonlPath = `${resolveSessionDir(process.cwd())}/${sessionId}.jsonl`;
|
|
344
|
+
await _runHILWatcher(
|
|
345
|
+
watch(jsonlPath, { signal }),
|
|
346
|
+
() => getSessionMessages(sessionId, { dir: process.cwd(), includeSystemMessages: true }),
|
|
347
|
+
onHIL,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
243
351
|
// ---------------------------------------------------------------------------
|
|
244
352
|
// Helpers
|
|
245
353
|
// ---------------------------------------------------------------------------
|
|
@@ -268,10 +376,13 @@ async function waitForIdle(
|
|
|
268
376
|
transcriptBeforeCount: number,
|
|
269
377
|
beforeContent: string,
|
|
270
378
|
pollIntervalMs: number,
|
|
379
|
+
onHIL?: (waiting: boolean) => void,
|
|
271
380
|
): Promise<SessionMessage[]> {
|
|
272
381
|
// Give Claude time to start processing before first poll
|
|
273
382
|
await Bun.sleep(3_000);
|
|
274
383
|
|
|
384
|
+
let hilActive = false;
|
|
385
|
+
|
|
275
386
|
while (true) {
|
|
276
387
|
const currentContent = normalizeTmuxLines(capturePaneScrollback(paneId));
|
|
277
388
|
|
|
@@ -279,13 +390,37 @@ async function waitForIdle(
|
|
|
279
390
|
if (currentContent !== beforeContent) {
|
|
280
391
|
const visible = capturePaneVisible(paneId);
|
|
281
392
|
if (paneLooksReady(visible) && !paneHasActiveTask(visible)) {
|
|
282
|
-
// Pane
|
|
393
|
+
// Pane looks idle — but it might be waiting for user input (HIL).
|
|
394
|
+
// Check the transcript for an unresolved AskUserQuestion before
|
|
395
|
+
// treating this as a true completion.
|
|
283
396
|
if (claudeSessionId) {
|
|
284
397
|
try {
|
|
285
398
|
const msgs = await getSessionMessages(claudeSessionId, {
|
|
286
399
|
dir: process.cwd(),
|
|
287
400
|
includeSystemMessages: true,
|
|
288
401
|
});
|
|
402
|
+
|
|
403
|
+
if (_hasUnresolvedHILTool(msgs)) {
|
|
404
|
+
// Agent is blocked on user input — signal HIL and keep waiting
|
|
405
|
+
if (!hilActive && onHIL) {
|
|
406
|
+
onHIL(true);
|
|
407
|
+
hilActive = true;
|
|
408
|
+
}
|
|
409
|
+
await Bun.sleep(pollIntervalMs);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// HIL was active but is now resolved — signal resumption
|
|
414
|
+
if (hilActive && onHIL) {
|
|
415
|
+
onHIL(false);
|
|
416
|
+
hilActive = false;
|
|
417
|
+
// Agent may still be processing after HIL resolution — keep
|
|
418
|
+
// polling instead of returning immediately
|
|
419
|
+
await Bun.sleep(pollIntervalMs);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Truly idle — return transcript messages from this turn
|
|
289
424
|
if (msgs.length > transcriptBeforeCount) {
|
|
290
425
|
return msgs.slice(transcriptBeforeCount);
|
|
291
426
|
}
|
|
@@ -294,6 +429,13 @@ async function waitForIdle(
|
|
|
294
429
|
}
|
|
295
430
|
}
|
|
296
431
|
return [];
|
|
432
|
+
} else if (hilActive) {
|
|
433
|
+
// Pane is active again (user responded, agent resumed processing).
|
|
434
|
+
// Clear HIL state.
|
|
435
|
+
if (onHIL) {
|
|
436
|
+
onHIL(false);
|
|
437
|
+
hilActive = false;
|
|
438
|
+
}
|
|
297
439
|
}
|
|
298
440
|
}
|
|
299
441
|
|
|
@@ -318,6 +460,12 @@ export interface ClaudeQueryOptions {
|
|
|
318
460
|
maxSubmitRounds?: number;
|
|
319
461
|
/** Timeout in ms waiting for pane to be ready before sending (default: 30s) */
|
|
320
462
|
readyTimeoutMs?: number;
|
|
463
|
+
/**
|
|
464
|
+
* Called when the agent's human-in-the-loop state changes.
|
|
465
|
+
* `waiting=true` → AskUserQuestion is pending (agent blocked on user input).
|
|
466
|
+
* `waiting=false` → AskUserQuestion was resolved (agent resumed processing).
|
|
467
|
+
*/
|
|
468
|
+
onHIL?: (waiting: boolean) => void;
|
|
321
469
|
}
|
|
322
470
|
|
|
323
471
|
/**
|
|
@@ -386,6 +534,7 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
|
|
|
386
534
|
submitPresses = 1,
|
|
387
535
|
maxSubmitRounds = 6,
|
|
388
536
|
readyTimeoutMs = 30_000,
|
|
537
|
+
onHIL,
|
|
389
538
|
} = options;
|
|
390
539
|
|
|
391
540
|
const paneState = initializedPanes.get(paneId);
|
|
@@ -485,12 +634,17 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
|
|
|
485
634
|
// Interactive Claude Code sessions don't write idle/result events to the
|
|
486
635
|
// JSONL. The pane prompt indicator is the only reliable idle signal.
|
|
487
636
|
// Once idle, output is extracted from the transcript when available.
|
|
488
|
-
|
|
637
|
+
//
|
|
638
|
+
// HIL detection is integrated into waitForIdle — when the pane looks idle
|
|
639
|
+
// but the transcript has an unresolved AskUserQuestion, the function
|
|
640
|
+
// calls onHIL(true) and keeps waiting instead of returning prematurely.
|
|
641
|
+
return await waitForIdle(
|
|
489
642
|
paneId,
|
|
490
643
|
claudeSessionId,
|
|
491
644
|
transcriptBeforeCount,
|
|
492
645
|
beforeContent,
|
|
493
646
|
pollIntervalMs,
|
|
647
|
+
onHIL,
|
|
494
648
|
);
|
|
495
649
|
}
|
|
496
650
|
|
|
@@ -550,15 +704,18 @@ export class ClaudeSessionWrapper {
|
|
|
550
704
|
readonly paneId: string;
|
|
551
705
|
readonly sessionId: string;
|
|
552
706
|
private readonly defaults: ClaudeQueryDefaults;
|
|
707
|
+
private readonly onHIL: ((waiting: boolean) => void) | undefined;
|
|
553
708
|
|
|
554
709
|
constructor(
|
|
555
710
|
paneId: string,
|
|
556
711
|
sessionId: string,
|
|
557
712
|
defaults: ClaudeQueryDefaults = {},
|
|
713
|
+
onHIL?: (waiting: boolean) => void,
|
|
558
714
|
) {
|
|
559
715
|
this.paneId = paneId;
|
|
560
716
|
this.sessionId = sessionId;
|
|
561
717
|
this.defaults = defaults;
|
|
718
|
+
this.onHIL = onHIL;
|
|
562
719
|
}
|
|
563
720
|
|
|
564
721
|
/** Send a prompt to Claude and wait for the response. */
|
|
@@ -571,6 +728,7 @@ export class ClaudeSessionWrapper {
|
|
|
571
728
|
prompt,
|
|
572
729
|
...this.defaults,
|
|
573
730
|
...opts,
|
|
731
|
+
onHIL: this.onHIL,
|
|
574
732
|
});
|
|
575
733
|
}
|
|
576
734
|
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
hasContent,
|
|
5
5
|
escBash,
|
|
6
6
|
escPwsh,
|
|
7
|
+
watchCopilotSessionForHIL,
|
|
8
|
+
type CopilotHILSessionSurface,
|
|
7
9
|
} from "./executor.ts";
|
|
8
10
|
import type { SavedMessage } from "../types.ts";
|
|
9
11
|
import type { SessionEvent } from "@github/copilot-sdk";
|
|
@@ -380,3 +382,145 @@ describe("escPwsh", () => {
|
|
|
380
382
|
expect(escPwsh('$`"\0')).toBe('`$```"');
|
|
381
383
|
});
|
|
382
384
|
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// watchCopilotSessionForHIL — event-driven HIL detection via tool.execution_*
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Minimal mock of the Copilot session surface that records handlers by event
|
|
392
|
+
* type and lets tests dispatch synthetic events. Mirrors the structural
|
|
393
|
+
* `on()` contract of `CopilotHILSessionSurface`.
|
|
394
|
+
*/
|
|
395
|
+
function makeMockCopilotSession(): CopilotHILSessionSurface & {
|
|
396
|
+
dispatch: (type: string, data: unknown) => void;
|
|
397
|
+
handlerCount: (type: string) => number;
|
|
398
|
+
} {
|
|
399
|
+
const handlers = new Map<string, Set<(event: { data?: unknown }) => void>>();
|
|
400
|
+
return {
|
|
401
|
+
on(eventType, handler) {
|
|
402
|
+
let set = handlers.get(eventType);
|
|
403
|
+
if (!set) {
|
|
404
|
+
set = new Set();
|
|
405
|
+
handlers.set(eventType, set);
|
|
406
|
+
}
|
|
407
|
+
set.add(handler);
|
|
408
|
+
return () => {
|
|
409
|
+
set!.delete(handler);
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
dispatch(type, data) {
|
|
413
|
+
const set = handlers.get(type);
|
|
414
|
+
if (set) for (const h of set) h({ data });
|
|
415
|
+
},
|
|
416
|
+
handlerCount(type) {
|
|
417
|
+
return handlers.get(type)?.size ?? 0;
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
describe("watchCopilotSessionForHIL", () => {
|
|
423
|
+
test("fires onHIL(true) on ask_user start and onHIL(false) on matching complete", () => {
|
|
424
|
+
const session = makeMockCopilotSession();
|
|
425
|
+
const calls: boolean[] = [];
|
|
426
|
+
const unsubscribe = watchCopilotSessionForHIL(session, (w) =>
|
|
427
|
+
calls.push(w),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
session.dispatch("tool.execution_start", {
|
|
431
|
+
toolName: "ask_user",
|
|
432
|
+
toolCallId: "tc-1",
|
|
433
|
+
});
|
|
434
|
+
expect(calls).toEqual([true]);
|
|
435
|
+
|
|
436
|
+
session.dispatch("tool.execution_complete", { toolCallId: "tc-1" });
|
|
437
|
+
expect(calls).toEqual([true, false]);
|
|
438
|
+
|
|
439
|
+
unsubscribe();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("ignores tool.execution_start for non-ask_user tools", () => {
|
|
443
|
+
const session = makeMockCopilotSession();
|
|
444
|
+
const calls: boolean[] = [];
|
|
445
|
+
const unsubscribe = watchCopilotSessionForHIL(session, (w) =>
|
|
446
|
+
calls.push(w),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
session.dispatch("tool.execution_start", {
|
|
450
|
+
toolName: "edit_file",
|
|
451
|
+
toolCallId: "tc-2",
|
|
452
|
+
});
|
|
453
|
+
session.dispatch("tool.execution_complete", { toolCallId: "tc-2" });
|
|
454
|
+
expect(calls).toEqual([]);
|
|
455
|
+
|
|
456
|
+
unsubscribe();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("ignores complete events for toolCallIds it did not mark active", () => {
|
|
460
|
+
const session = makeMockCopilotSession();
|
|
461
|
+
const calls: boolean[] = [];
|
|
462
|
+
const unsubscribe = watchCopilotSessionForHIL(session, (w) =>
|
|
463
|
+
calls.push(w),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// complete arrives for a tool we never started (e.g. another tool's id)
|
|
467
|
+
session.dispatch("tool.execution_complete", { toolCallId: "tc-unknown" });
|
|
468
|
+
expect(calls).toEqual([]);
|
|
469
|
+
|
|
470
|
+
unsubscribe();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("only fires onHIL(false) after the last overlapping ask_user completes", () => {
|
|
474
|
+
const session = makeMockCopilotSession();
|
|
475
|
+
const calls: boolean[] = [];
|
|
476
|
+
const unsubscribe = watchCopilotSessionForHIL(session, (w) =>
|
|
477
|
+
calls.push(w),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
session.dispatch("tool.execution_start", {
|
|
481
|
+
toolName: "ask_user",
|
|
482
|
+
toolCallId: "tc-a",
|
|
483
|
+
});
|
|
484
|
+
session.dispatch("tool.execution_start", {
|
|
485
|
+
toolName: "ask_user",
|
|
486
|
+
toolCallId: "tc-b",
|
|
487
|
+
});
|
|
488
|
+
// onHIL(true) fires exactly once on the first start
|
|
489
|
+
expect(calls).toEqual([true]);
|
|
490
|
+
|
|
491
|
+
session.dispatch("tool.execution_complete", { toolCallId: "tc-a" });
|
|
492
|
+
// still one active — must not fire onHIL(false) yet
|
|
493
|
+
expect(calls).toEqual([true]);
|
|
494
|
+
|
|
495
|
+
session.dispatch("tool.execution_complete", { toolCallId: "tc-b" });
|
|
496
|
+
expect(calls).toEqual([true, false]);
|
|
497
|
+
|
|
498
|
+
unsubscribe();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("skips ask_user start events that are missing a toolCallId", () => {
|
|
502
|
+
const session = makeMockCopilotSession();
|
|
503
|
+
const calls: boolean[] = [];
|
|
504
|
+
const unsubscribe = watchCopilotSessionForHIL(session, (w) =>
|
|
505
|
+
calls.push(w),
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
session.dispatch("tool.execution_start", { toolName: "ask_user" });
|
|
509
|
+
expect(calls).toEqual([]);
|
|
510
|
+
|
|
511
|
+
unsubscribe();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("unsubscribe removes both listeners", () => {
|
|
515
|
+
const session = makeMockCopilotSession();
|
|
516
|
+
const unsubscribe = watchCopilotSessionForHIL(session, () => {});
|
|
517
|
+
|
|
518
|
+
expect(session.handlerCount("tool.execution_start")).toBe(1);
|
|
519
|
+
expect(session.handlerCount("tool.execution_complete")).toBe(1);
|
|
520
|
+
|
|
521
|
+
unsubscribe();
|
|
522
|
+
|
|
523
|
+
expect(session.handlerCount("tool.execution_start")).toBe(0);
|
|
524
|
+
expect(session.handlerCount("tool.execution_complete")).toBe(0);
|
|
525
|
+
});
|
|
526
|
+
});
|