@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.
Files changed (38) hide show
  1. package/.agents/skills/workflow-creator/references/agent-sessions.md +3 -1
  2. package/.agents/skills/workflow-creator/references/failure-modes.md +140 -0
  3. package/.claude/settings.json +1 -0
  4. package/dist/sdk/components/header.d.ts.map +1 -1
  5. package/dist/sdk/components/layout.d.ts.map +1 -1
  6. package/dist/sdk/components/node-card.d.ts.map +1 -1
  7. package/dist/sdk/components/orchestrator-panel-store.d.ts +2 -0
  8. package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -1
  9. package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -1
  10. package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -1
  11. package/dist/sdk/components/orchestrator-panel.d.ts +2 -0
  12. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
  13. package/dist/sdk/components/status-helpers.d.ts.map +1 -1
  14. package/dist/sdk/providers/claude.d.ts +33 -1
  15. package/dist/sdk/providers/claude.d.ts.map +1 -1
  16. package/dist/sdk/runtime/executor.d.ts +88 -0
  17. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  18. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
  19. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts +7 -3
  20. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -1
  21. package/dist/services/config/definitions.d.ts.map +1 -1
  22. package/package.json +5 -5
  23. package/src/commands/cli/init/onboarding.ts +19 -2
  24. package/src/sdk/components/header.tsx +2 -1
  25. package/src/sdk/components/layout.ts +2 -1
  26. package/src/sdk/components/node-card.tsx +16 -0
  27. package/src/sdk/components/orchestrator-panel-store.test.ts +88 -0
  28. package/src/sdk/components/orchestrator-panel-store.ts +16 -0
  29. package/src/sdk/components/orchestrator-panel-types.ts +1 -1
  30. package/src/sdk/components/orchestrator-panel.tsx +8 -0
  31. package/src/sdk/components/session-graph-panel.tsx +1 -1
  32. package/src/sdk/components/status-helpers.ts +3 -2
  33. package/src/sdk/providers/claude.ts +160 -2
  34. package/src/sdk/runtime/executor.test.ts +144 -0
  35. package/src/sdk/runtime/executor.ts +219 -31
  36. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +3 -18
  37. package/src/sdk/workflows/builtin/ralph/claude/index.ts +41 -51
  38. 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 is idle — return transcript messages from this turn
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
- return waitForIdle(
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
+ });