@app-vitals/agent-runtime 0.0.1 → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,3042 @@
1
+ /**
2
+ * Tests for agent/src/runtime/claude.ts
3
+ *
4
+ * Strategy: use createRunClaude(spawner, sessions, model, workspace)
5
+ * to inject all dependencies directly. No mock.module() calls needed anywhere.
6
+ * Note: Identity injection removed — identity is now handled via CLAUDE.md in the workspace.
7
+ */
8
+
9
+ import "./test-env.ts";
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+ import {
12
+ existsSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
21
+ import { TEST_AGENT_HOME } from "./test-env.ts";
22
+
23
+ const MODEL = "claude-opus-4-6";
24
+ const WORKSPACE = join(TEST_AGENT_HOME, "workspace");
25
+
26
+ // ─── Import module under test ─────────────────────────────────────────────────
27
+
28
+ const {
29
+ createRunClaude,
30
+ setLiveClaudeConfig,
31
+ dominantModel,
32
+ ClaudeTimeoutError,
33
+ reportClaudeError,
34
+ ClaudeRunError,
35
+ } = await import("./claude.ts");
36
+
37
+ // ─── Stream-json fixtures (hand-authored per the public CLI schema) ───────────
38
+
39
+ const cleanMultiTurn = await import(
40
+ "./fixtures/stream-json/clean-multi-turn.ts"
41
+ );
42
+ const truncatedNoResult = await import(
43
+ "./fixtures/stream-json/truncated-no-result.ts"
44
+ );
45
+ const cleanExitNoResult = await import(
46
+ "./fixtures/stream-json/clean-exit-no-result.ts"
47
+ );
48
+ const progressPhases = await import(
49
+ "./fixtures/stream-json/progress-phases.ts"
50
+ );
51
+ const skillAttribution = await import(
52
+ "./fixtures/stream-json/skill-attribution.ts"
53
+ );
54
+
55
+ // ─── Shared test session store ────────────────────────────────────────────────
56
+
57
+ const mockGetSession = mock((_key: string): string | undefined => undefined);
58
+ const mockSetSession = mock((_key: string, _id: string): void => {});
59
+ const mockClearSession = mock((_key: string): void => {});
60
+ const testSessions = {
61
+ get: mockGetSession,
62
+ set: mockSetSession,
63
+ clear: mockClearSession,
64
+ };
65
+
66
+ let capturedMessages: string[] = [];
67
+ let capturedExceptions: unknown[] = [];
68
+ const fakeSentryClient: ErrorCapturingClient = {
69
+ captureException: (err: unknown) => {
70
+ capturedExceptions.push(err);
71
+ },
72
+ captureMessage: (message: string) => {
73
+ capturedMessages.push(message);
74
+ },
75
+ };
76
+
77
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
78
+
79
+ function bodyStream(content: string): ReadableStream<Uint8Array> {
80
+ const enc = new TextEncoder();
81
+ return new ReadableStream({
82
+ start(controller) {
83
+ controller.enqueue(enc.encode(content));
84
+ controller.close();
85
+ },
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Emits each NDJSON line as its OWN stream chunk (with a trailing newline), so
91
+ * the parser under test is exercised across real chunk boundaries rather than
92
+ * one giant buffered string.
93
+ */
94
+ function ndjsonStream(lines: string[]): ReadableStream<Uint8Array> {
95
+ const enc = new TextEncoder();
96
+ return new ReadableStream({
97
+ start(controller) {
98
+ for (const line of lines) {
99
+ controller.enqueue(enc.encode(`${line}\n`));
100
+ }
101
+ controller.close();
102
+ },
103
+ });
104
+ }
105
+
106
+ function ndjsonProc(lines: string[], stderr = "", exitCode = 0): FakeProc {
107
+ return {
108
+ stdout: ndjsonStream(lines),
109
+ stderr: bodyStream(stderr),
110
+ exited: Promise.resolve(exitCode),
111
+ kill: () => {},
112
+ };
113
+ }
114
+
115
+ interface FakeProc {
116
+ stdout: ReadableStream<Uint8Array>;
117
+ stderr: ReadableStream<Uint8Array>;
118
+ exited: Promise<number>;
119
+ kill: () => void;
120
+ }
121
+
122
+ function fakeProc(stdout: string, stderr = "", exitCode = 0): FakeProc {
123
+ return {
124
+ stdout: bodyStream(stdout),
125
+ stderr: bodyStream(stderr),
126
+ exited: Promise.resolve(exitCode),
127
+ kill: () => {},
128
+ };
129
+ }
130
+
131
+ /**
132
+ * A fake proc whose stdout emits one NDJSON line every `intervalMs`, for
133
+ * `count` lines — used to exercise idle-reset-vs-ceiling timer races with
134
+ * short REAL timeouts (consistent with this file's existing convention of
135
+ * small millisecond literals rather than fake-timer injection).
136
+ *
137
+ * When `autoCloseAfterDrip` is true, the stream closes itself (and `exited`
138
+ * resolves with 0) right after the last line — simulating a process that
139
+ * finishes cleanly before any timer fires. When false, the stream is left
140
+ * open after the last line (no close(), no result line) and only `kill()`
141
+ * (called by the idle/ceiling timer under test) closes it — simulating a
142
+ * process a timer has to forcibly stop.
143
+ */
144
+ function drippingProc(
145
+ line: string,
146
+ intervalMs: number,
147
+ count: number,
148
+ autoCloseAfterDrip = false,
149
+ ): FakeProc {
150
+ const enc = new TextEncoder();
151
+ let resolveExited!: (code: number) => void;
152
+ const exited = new Promise<number>((r) => {
153
+ resolveExited = r;
154
+ });
155
+ let streamController!: ReadableStreamDefaultController<Uint8Array>;
156
+ let closed = false;
157
+ const close = (code: number) => {
158
+ if (closed) return;
159
+ closed = true;
160
+ streamController.close();
161
+ resolveExited(code);
162
+ };
163
+ const stdout = new ReadableStream<Uint8Array>({
164
+ start(controller) {
165
+ streamController = controller;
166
+ (async () => {
167
+ for (let i = 0; i < count; i++) {
168
+ if (closed) return;
169
+ controller.enqueue(enc.encode(`${line}\n`));
170
+ await new Promise((r) => setTimeout(r, intervalMs));
171
+ }
172
+ if (autoCloseAfterDrip) close(0);
173
+ })();
174
+ },
175
+ });
176
+ return {
177
+ stdout,
178
+ stderr: bodyStream(""),
179
+ exited,
180
+ kill: () => close(143),
181
+ };
182
+ }
183
+
184
+ function hangingProc(): FakeProc & { triggerKill: () => void } {
185
+ let resolveExited!: (code: number) => void;
186
+ const exited = new Promise<number>((r) => {
187
+ resolveExited = r;
188
+ });
189
+ const kill = () => resolveExited(143);
190
+ return {
191
+ stdout: bodyStream(""),
192
+ stderr: bodyStream(""),
193
+ exited,
194
+ kill,
195
+ triggerKill: kill,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * A single terminal stream-json `result` line (the CLI emits exactly one on a
201
+ * clean finish). Its shape is byte-identical to the old `--output-format json`
202
+ * single blob, plus the `type`/`subtype` discriminator.
203
+ */
204
+ function jsonOutput(
205
+ result: string,
206
+ sessionId = "sess-abc",
207
+ isError = false,
208
+ ): string {
209
+ return JSON.stringify({
210
+ type: "result",
211
+ subtype: isError ? "error" : "success",
212
+ result,
213
+ session_id: sessionId,
214
+ is_error: isError,
215
+ });
216
+ }
217
+
218
+ // ─── runClaude tests ──────────────────────────────────────────────────────────
219
+
220
+ describe("runClaude", () => {
221
+ // biome-ignore lint/suspicious/noExplicitAny: mock return type
222
+ let mockSpawn: any;
223
+ let runClaude: ReturnType<typeof createRunClaude>;
224
+
225
+ beforeEach(() => {
226
+ mockGetSession.mockClear();
227
+ mockSetSession.mockClear();
228
+ mockClearSession.mockClear();
229
+ capturedMessages = [];
230
+ capturedExceptions = [];
231
+ mockSpawn = mock(
232
+ () =>
233
+ fakeProc(jsonOutput("Hello from Claude")) as ReturnType<
234
+ typeof Bun.spawn
235
+ >,
236
+ );
237
+ runClaude = createRunClaude(
238
+ mockSpawn as typeof Bun.spawn,
239
+ testSessions,
240
+ MODEL,
241
+ WORKSPACE,
242
+ fakeSentryClient,
243
+ );
244
+ });
245
+
246
+ test("calls spawn with claude as the command", async () => {
247
+ await runClaude("test message");
248
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
249
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
250
+ expect(cmd[0]).toBe("claude");
251
+ });
252
+
253
+ test("passes -p flag for non-interactive mode", async () => {
254
+ await runClaude("test");
255
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
256
+ expect(cmd).toContain("-p");
257
+ });
258
+
259
+ test("passes --output-format stream-json --verbose", async () => {
260
+ await runClaude("test");
261
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
262
+ const idx = cmd.indexOf("--output-format");
263
+ expect(idx).toBeGreaterThan(-1);
264
+ expect(cmd[idx + 1]).toBe("stream-json");
265
+ expect(cmd).toContain("--verbose");
266
+ });
267
+
268
+ test("passes --model from config", async () => {
269
+ await runClaude("test");
270
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
271
+ const idx = cmd.indexOf("--model");
272
+ expect(idx).toBeGreaterThan(-1);
273
+ expect(cmd[idx + 1]).toBe("claude-opus-4-6");
274
+ });
275
+
276
+ test("does not pass --fallback-model when not configured", async () => {
277
+ await runClaude("test");
278
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
279
+ expect(cmd).not.toContain("--fallback-model");
280
+ });
281
+
282
+ test("does not pass --effort when not configured", async () => {
283
+ await runClaude("test");
284
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
285
+ expect(cmd).not.toContain("--effort");
286
+ });
287
+
288
+ test("passes --effort when configured", async () => {
289
+ const runClaudeWithEffort = createRunClaude(
290
+ mockSpawn as typeof Bun.spawn,
291
+ testSessions,
292
+ MODEL,
293
+ WORKSPACE,
294
+ fakeSentryClient,
295
+ [],
296
+ undefined,
297
+ "xhigh",
298
+ );
299
+ await runClaudeWithEffort("test");
300
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
301
+ const idx = cmd.indexOf("--effort");
302
+ expect(idx).toBeGreaterThan(-1);
303
+ expect(cmd[idx + 1]).toBe("xhigh");
304
+ });
305
+
306
+ test("passes --fallback-model when configured", async () => {
307
+ const runClaudeWithFallback = createRunClaude(
308
+ mockSpawn as typeof Bun.spawn,
309
+ testSessions,
310
+ MODEL,
311
+ WORKSPACE,
312
+ fakeSentryClient,
313
+ [],
314
+ "claude-sonnet-4-6",
315
+ );
316
+ await runClaudeWithFallback("test");
317
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
318
+ const idx = cmd.indexOf("--fallback-model");
319
+ expect(idx).toBeGreaterThan(-1);
320
+ expect(cmd[idx + 1]).toBe("claude-sonnet-4-6");
321
+ });
322
+
323
+ test("passes correct cwd, stdout, stderr spawn options", async () => {
324
+ await runClaude("test");
325
+ const [, opts] = mockSpawn.mock.calls[0] as [
326
+ string[],
327
+ { cwd: string; stdout: string; stderr: string },
328
+ ];
329
+ expect(opts.cwd).toBe(join(TEST_AGENT_HOME, "workspace"));
330
+ expect(opts.stdout).toBe("pipe");
331
+ expect(opts.stderr).toBe("pipe");
332
+ });
333
+
334
+ test("strips SENTRY_DSN from the spawned child's env but keeps unrelated vars", async () => {
335
+ const priorSentryDsn = process.env.SENTRY_DSN;
336
+ process.env.SENTRY_DSN = "https://fake@sentry.example/123";
337
+ try {
338
+ await runClaude("test");
339
+ const [, opts] = mockSpawn.mock.calls[0] as [
340
+ string[],
341
+ { env: Record<string, string | undefined> },
342
+ ];
343
+ expect(opts.env.SENTRY_DSN).toBeUndefined();
344
+ expect(opts.env.PATH).toBe(process.env.PATH);
345
+ } finally {
346
+ if (priorSentryDsn === undefined) {
347
+ delete process.env.SENTRY_DSN;
348
+ } else {
349
+ process.env.SENTRY_DSN = priorSentryDsn;
350
+ }
351
+ }
352
+ });
353
+
354
+ test("layers extraEnv on top of the spawned child's env alongside existing vars", async () => {
355
+ await runClaude("test", undefined, undefined, undefined, {
356
+ MY_VAR: "my-value",
357
+ });
358
+ const [, opts] = mockSpawn.mock.calls[0] as [
359
+ string[],
360
+ { env: Record<string, string | undefined> },
361
+ ];
362
+ expect(opts.env.MY_VAR).toBe("my-value");
363
+ expect(opts.env.PATH).toBe(process.env.PATH);
364
+ });
365
+
366
+ test("passes message as last arg without identity injection", async () => {
367
+ await runClaude("my prompt");
368
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
369
+ const lastArg = cmd[cmd.length - 1];
370
+ expect(lastArg).toBe("my prompt");
371
+ });
372
+
373
+ test("passes raw message when resuming an existing session", async () => {
374
+ mockGetSession.mockReturnValueOnce("existing-sid");
375
+ await runClaude("follow-up question", "chan:ts");
376
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
377
+ const lastArg = cmd[cmd.length - 1];
378
+ expect(lastArg).toBe("follow-up question");
379
+ });
380
+
381
+ test("returns result and sessionId on success", async () => {
382
+ const output = await runClaude("hello");
383
+ expect(output.result).toBe("Hello from Claude");
384
+ expect(output.sessionId).toBe("sess-abc");
385
+ });
386
+
387
+ test("returns usage from JSON output when present", async () => {
388
+ const usageJson = JSON.stringify({
389
+ type: "result",
390
+ subtype: "success",
391
+ result: "Hello with usage",
392
+ session_id: "sess-usage",
393
+ is_error: false,
394
+ usage: {
395
+ input_tokens: 100,
396
+ output_tokens: 50,
397
+ cache_read_input_tokens: 25,
398
+ cache_creation_input_tokens: 10,
399
+ },
400
+ });
401
+ mockSpawn.mockReturnValue(
402
+ fakeProc(usageJson) as ReturnType<typeof Bun.spawn>,
403
+ );
404
+ const output = await runClaude("hello");
405
+ expect(output.usage).toEqual({
406
+ input_tokens: 100,
407
+ output_tokens: 50,
408
+ cache_read_input_tokens: 25,
409
+ cache_creation_input_tokens: 10,
410
+ });
411
+ });
412
+
413
+ test("returns undefined usage when not in JSON output", async () => {
414
+ // Default mockSpawn produces jsonOutput("Hello from Claude") which has no usage field
415
+ const output = await runClaude("hello");
416
+ expect(output.usage).toBeUndefined();
417
+ });
418
+
419
+ test("without sessionKey does not call getSession or setSession", async () => {
420
+ await runClaude("hello");
421
+ expect(mockGetSession).not.toHaveBeenCalled();
422
+ expect(mockSetSession).not.toHaveBeenCalled();
423
+ });
424
+
425
+ test("with sessionKey calls getSession to look up existing session", async () => {
426
+ await runClaude("hello", "chan:ts");
427
+ expect(mockGetSession).toHaveBeenCalledWith("chan:ts");
428
+ });
429
+
430
+ test("adds -r flag when existing session is found", async () => {
431
+ mockGetSession.mockReturnValueOnce("existing-sid");
432
+ await runClaude("hello", "chan:ts");
433
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
434
+ const rIdx = cmd.indexOf("-r");
435
+ expect(rIdx).toBeGreaterThan(-1);
436
+ expect(cmd[rIdx + 1]).toBe("existing-sid");
437
+ });
438
+
439
+ test("omits -r flag when no existing session", async () => {
440
+ mockGetSession.mockReturnValueOnce(undefined);
441
+ await runClaude("hello", "chan:ts");
442
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
443
+ expect(cmd).not.toContain("-r");
444
+ });
445
+
446
+ test("saves session after successful run when sessionKey provided", async () => {
447
+ await runClaude("hello", "chan:ts");
448
+ expect(mockSetSession).toHaveBeenCalledWith("chan:ts", "sess-abc");
449
+ });
450
+
451
+ test("throws on non-zero exit code", async () => {
452
+ mockSpawn.mockReturnValue(
453
+ fakeProc("", "claude: command not found", 127) as ReturnType<
454
+ typeof Bun.spawn
455
+ >,
456
+ );
457
+ await expect(runClaude("hello")).rejects.toThrow("claude exited 127");
458
+ });
459
+
460
+ test("non-zero exit error includes stderr in message", async () => {
461
+ mockSpawn.mockReturnValue(
462
+ fakeProc("", "auth failure", 1) as ReturnType<typeof Bun.spawn>,
463
+ );
464
+ await expect(runClaude("hello")).rejects.toThrow("auth failure");
465
+ });
466
+
467
+ test("non-zero exit falls back to stdout when stderr is empty", async () => {
468
+ mockSpawn.mockReturnValue(
469
+ fakeProc("stdout fallback msg", "", 2) as ReturnType<typeof Bun.spawn>,
470
+ );
471
+ await expect(runClaude("hello")).rejects.toThrow("stdout fallback msg");
472
+ });
473
+
474
+ test("throws on is_error=true in response", async () => {
475
+ mockSpawn.mockReturnValue(
476
+ fakeProc(jsonOutput("tool call failed", "sess-abc", true)) as ReturnType<
477
+ typeof Bun.spawn
478
+ >,
479
+ );
480
+ await expect(runClaude("hello")).rejects.toThrow(
481
+ "claude error: tool call failed",
482
+ );
483
+ });
484
+
485
+ test("saves the session id from a ClaudeRunError on is_error=true (CSI-1.2: a real session exists even when the run itself errored)", async () => {
486
+ mockSpawn.mockReturnValue(
487
+ fakeProc(jsonOutput("tool error", "sess-abc", true)) as ReturnType<
488
+ typeof Bun.spawn
489
+ >,
490
+ );
491
+ await expect(runClaude("hello", "chan:ts")).rejects.toThrow();
492
+ expect(mockSetSession).toHaveBeenCalledWith("chan:ts", "sess-abc");
493
+ });
494
+
495
+ test("throws ClaudeTimeoutError when process exceeds timeoutMs", async () => {
496
+ const proc = hangingProc();
497
+ const mockSpawnTimeout = mock(
498
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
499
+ );
500
+ const runClaudeWithTimeout = createRunClaude(
501
+ mockSpawnTimeout as typeof Bun.spawn,
502
+ testSessions,
503
+ MODEL,
504
+ WORKSPACE,
505
+ fakeSentryClient,
506
+ undefined,
507
+ undefined,
508
+ undefined,
509
+ 1000, // 1s ceiling headroom — well clear of the idle timeout below
510
+ 10, // 10ms idle timeout — hangingProc never emits a line, so idle fires first
511
+ );
512
+
513
+ const { ClaudeTimeoutError } = await import("./claude.ts");
514
+ const err = await runClaudeWithTimeout("hello").catch((e) => e);
515
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
516
+ const timeoutErr = err as InstanceType<typeof ClaudeTimeoutError>;
517
+ expect(timeoutErr.reason).toBe("idle");
518
+ // Must report the idle window (10ms) that actually fired, not the 1000ms
519
+ // ceiling headroom that never lapsed.
520
+ expect(timeoutErr.timeoutMs).toBe(10);
521
+ });
522
+
523
+ test("throws ClaudeTimeoutError on an idle-reason stale-session timeout and does not retry (spawn called once)", async () => {
524
+ const proc = hangingProc();
525
+ const mockSpawnTimeout = mock(
526
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
527
+ );
528
+
529
+ mockGetSession.mockClear();
530
+ mockSetSession.mockClear();
531
+ mockClearSession.mockClear();
532
+ // Simulate an existing session so we enter the stale-session catch block
533
+ mockGetSession.mockReturnValueOnce("stale-sid");
534
+
535
+ const runClaudeWithTimeout = createRunClaude(
536
+ mockSpawnTimeout as typeof Bun.spawn,
537
+ testSessions,
538
+ MODEL,
539
+ WORKSPACE,
540
+ fakeSentryClient,
541
+ undefined,
542
+ undefined,
543
+ undefined,
544
+ 5000, // ceiling far away — idle must fire first, deterministically
545
+ 10, // 10ms idle timeout — hangingProc never emits a line, so idle fires
546
+ );
547
+
548
+ const { ClaudeTimeoutError } = await import("./claude.ts");
549
+ const err = await runClaudeWithTimeout("hello", "chan:ts").catch((e) => e);
550
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
551
+ expect((err as InstanceType<typeof ClaudeTimeoutError>).reason).toBe(
552
+ "idle",
553
+ );
554
+ // Idle is the genuine-hang signal — the safety rail prevents retry even
555
+ // with an existing session available to resume. Spawn called once.
556
+ expect(mockSpawnTimeout).toHaveBeenCalledTimes(1);
557
+ });
558
+
559
+ test("a ceiling-reason timeout IS retried once when a session id is available to resume", async () => {
560
+ // Fresh hangingProc() per spawn call — a real proc's stdout stream can
561
+ // only be consumed once, so the mock must hand back a new instance on
562
+ // each invocation (same pattern as the existing resume-retry tests).
563
+ let call = 0;
564
+ const mockSpawnCeiling = mock(() => {
565
+ call++;
566
+ if (call === 1) {
567
+ return hangingProc() as unknown as ReturnType<typeof Bun.spawn>;
568
+ }
569
+ return fakeProc(jsonOutput("recovered", "existing-sid")) as ReturnType<
570
+ typeof Bun.spawn
571
+ >;
572
+ });
573
+
574
+ mockGetSession.mockClear();
575
+ mockSetSession.mockClear();
576
+ mockClearSession.mockClear();
577
+ mockGetSession.mockReturnValueOnce("existing-sid");
578
+
579
+ const runClaudeCeilingRetry = createRunClaude(
580
+ mockSpawnCeiling as typeof Bun.spawn,
581
+ testSessions,
582
+ MODEL,
583
+ WORKSPACE,
584
+ fakeSentryClient,
585
+ undefined,
586
+ undefined,
587
+ undefined,
588
+ 10, // 10ms ceiling — fires deterministically before idle
589
+ 5000, // idle far away — ceiling must fire first
590
+ );
591
+
592
+ const result = await runClaudeCeilingRetry("hello", "chan:ts");
593
+ expect(result.result).toBe("recovered");
594
+ expect(result.recoveredFromError).toBe(true);
595
+ expect(mockSpawnCeiling).toHaveBeenCalledTimes(2);
596
+
597
+ const [secondCmd] = mockSpawnCeiling.mock.calls[1] as unknown as [string[]];
598
+ const rIdx = secondCmd.indexOf("-r");
599
+ expect(rIdx).toBeGreaterThan(-1);
600
+ expect(secondCmd[rIdx + 1]).toBe("existing-sid");
601
+ });
602
+
603
+ test("a fresh call that captures a session id mid-attempt (off the init line) is retried once with -r <that id>", async () => {
604
+ // First attempt: emits a system/init line (captures a session id via the
605
+ // new early-session-id path), then exits non-zero with no accumulated
606
+ // usage — this throws a plain Error carrying NO sessionId of its own, so
607
+ // the only way this retry can happen is via the mid-attempt capture, not
608
+ // via err.sessionId.
609
+ let call = 0;
610
+ const mockSpawn = mock(() => {
611
+ call++;
612
+ if (call === 1) {
613
+ return {
614
+ stdout: ndjsonStream([
615
+ JSON.stringify({
616
+ type: "system",
617
+ subtype: "init",
618
+ session_id: "early-sid",
619
+ }),
620
+ ]),
621
+ stderr: bodyStream("socket closed"),
622
+ exited: Promise.resolve(1),
623
+ kill: () => {},
624
+ } as unknown as ReturnType<typeof Bun.spawn>;
625
+ }
626
+ return fakeProc(jsonOutput("recovered", "early-sid")) as ReturnType<
627
+ typeof Bun.spawn
628
+ >;
629
+ });
630
+
631
+ mockGetSession.mockClear();
632
+ mockSetSession.mockClear();
633
+ mockClearSession.mockClear();
634
+ mockGetSession.mockReturnValue(undefined); // fresh — no existing session
635
+
636
+ const runClaudeFreshRetry = createRunClaude(
637
+ mockSpawn as typeof Bun.spawn,
638
+ testSessions,
639
+ MODEL,
640
+ WORKSPACE,
641
+ fakeSentryClient,
642
+ );
643
+
644
+ const result = await runClaudeFreshRetry("hello", "chan:ts");
645
+
646
+ expect(result.result).toBe("recovered");
647
+ expect(result.recoveredFromError).toBe(true);
648
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
649
+
650
+ // First (fresh) call must NOT have passed -r.
651
+ const [firstCmd] = mockSpawn.mock.calls[0] as unknown as [string[]];
652
+ expect(firstCmd.indexOf("-r")).toBe(-1);
653
+
654
+ // Retry must resume with the id captured mid-first-attempt.
655
+ const [secondCmd] = mockSpawn.mock.calls[1] as unknown as [string[]];
656
+ const rIdx = secondCmd.indexOf("-r");
657
+ expect(rIdx).toBeGreaterThan(-1);
658
+ expect(secondCmd[rIdx + 1]).toBe("early-sid");
659
+ expect(mockSetSession).toHaveBeenCalledWith("chan:ts", "early-sid");
660
+ });
661
+
662
+ test("the early-session-id callback fires before the per-call onProgress callback for the same run", async () => {
663
+ const lines = [
664
+ JSON.stringify({
665
+ type: "system",
666
+ subtype: "init",
667
+ session_id: "sess-cb",
668
+ }),
669
+ JSON.stringify({
670
+ type: "assistant",
671
+ message: {
672
+ id: "msg_1",
673
+ role: "assistant",
674
+ model: MODEL,
675
+ usage: {
676
+ input_tokens: 1,
677
+ output_tokens: 1,
678
+ cache_read_input_tokens: 0,
679
+ cache_creation_input_tokens: 0,
680
+ },
681
+ },
682
+ }),
683
+ jsonOutput("done", "sess-cb"),
684
+ ];
685
+ const mockSpawnEarly = mock(
686
+ () => ndjsonProc(lines) as ReturnType<typeof Bun.spawn>,
687
+ );
688
+ const runClaudeEarly = createRunClaude(
689
+ mockSpawnEarly as typeof Bun.spawn,
690
+ testSessions,
691
+ MODEL,
692
+ WORKSPACE,
693
+ fakeSentryClient,
694
+ );
695
+
696
+ const calls: string[] = [];
697
+ await runClaudeEarly(
698
+ "hello",
699
+ undefined,
700
+ () => calls.push("progress"),
701
+ undefined,
702
+ undefined,
703
+ () => calls.push("early"),
704
+ );
705
+
706
+ expect(calls).toEqual(["early", "progress"]);
707
+ });
708
+
709
+ // ─── Abort / cancel ──────────────────────────────────────────────────────────
710
+
711
+ /**
712
+ * Wrap a real AbortController's signal so addEventListener/removeEventListener
713
+ * calls for the "abort" event are counted. Not a bare mock — event dispatch
714
+ * still works, so a listener that is actually added will still fire on abort.
715
+ * The counts prove the listener is removed after the run (no leak across the
716
+ * long-lived signal).
717
+ */
718
+ function countedSignal() {
719
+ const controller = new AbortController();
720
+ const real = controller.signal;
721
+ let added = 0;
722
+ let removed = 0;
723
+ const wrapped = {
724
+ get aborted() {
725
+ return real.aborted;
726
+ },
727
+ get reason() {
728
+ return real.reason;
729
+ },
730
+ addEventListener(type: string, cb: EventListener, opts?: unknown) {
731
+ if (type === "abort") added++;
732
+ real.addEventListener(type, cb, opts as AddEventListenerOptions);
733
+ },
734
+ removeEventListener(type: string, cb: EventListener, opts?: unknown) {
735
+ if (type === "abort") removed++;
736
+ real.removeEventListener(type, cb, opts as EventListenerOptions);
737
+ },
738
+ } as unknown as AbortSignal;
739
+ return {
740
+ signal: wrapped,
741
+ abort: () => controller.abort(),
742
+ counts: () => ({ added, removed }),
743
+ };
744
+ }
745
+
746
+ test("aborting the signal kills the process and throws ClaudeAbortedError", async () => {
747
+ const proc = hangingProc();
748
+ const mockSpawnAbort = mock(
749
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
750
+ );
751
+ const runClaudeAbort = createRunClaude(
752
+ mockSpawnAbort as typeof Bun.spawn,
753
+ testSessions,
754
+ MODEL,
755
+ WORKSPACE,
756
+ fakeSentryClient,
757
+ undefined,
758
+ undefined,
759
+ undefined,
760
+ 5000,
761
+ 5000,
762
+ );
763
+
764
+ const ctl = countedSignal();
765
+ const { ClaudeAbortedError } = await import("./claude.ts");
766
+ const runPromise = runClaudeAbort(
767
+ "hello",
768
+ undefined,
769
+ undefined,
770
+ ctl.signal,
771
+ );
772
+ // Abort shortly after the run starts.
773
+ setTimeout(() => ctl.abort(), 5);
774
+ const err = await runPromise.catch((e) => e);
775
+ expect(err).toBeInstanceOf(ClaudeAbortedError);
776
+ });
777
+
778
+ test("the abort listener is removed after the run (no leak across a long-lived signal)", async () => {
779
+ // A run that finishes cleanly (no abort) must still remove its listener.
780
+ const mockSpawnClean = mock(
781
+ () =>
782
+ fakeProc(jsonOutput("done")) as unknown as ReturnType<typeof Bun.spawn>,
783
+ );
784
+ const runClaudeClean = createRunClaude(
785
+ mockSpawnClean as typeof Bun.spawn,
786
+ testSessions,
787
+ MODEL,
788
+ WORKSPACE,
789
+ fakeSentryClient,
790
+ );
791
+
792
+ const ctl = countedSignal();
793
+ await runClaudeClean("hello", undefined, undefined, ctl.signal);
794
+ const { added, removed } = ctl.counts();
795
+ expect(added).toBe(1);
796
+ expect(removed).toBe(1);
797
+ });
798
+
799
+ test("the abort listener is removed even across a resume-retry (no second leaked listener)", async () => {
800
+ // First spawn fails (retryable), second succeeds — the retry path must not
801
+ // leak a second listener on the shared signal.
802
+ let call = 0;
803
+ const mockSpawnRetry = mock(() => {
804
+ call++;
805
+ if (call === 1) {
806
+ return fakeProc("", "socket closed", 1) as unknown as ReturnType<
807
+ typeof Bun.spawn
808
+ >;
809
+ }
810
+ return fakeProc(
811
+ jsonOutput("recovered", "sess-r"),
812
+ ) as unknown as ReturnType<typeof Bun.spawn>;
813
+ });
814
+
815
+ mockGetSession.mockClear();
816
+ mockGetSession.mockReturnValueOnce("existing-sid");
817
+
818
+ const runClaudeRetry = createRunClaude(
819
+ mockSpawnRetry as typeof Bun.spawn,
820
+ testSessions,
821
+ MODEL,
822
+ WORKSPACE,
823
+ fakeSentryClient,
824
+ );
825
+
826
+ const ctl = countedSignal();
827
+ await runClaudeRetry("hello", "chan:ts", undefined, ctl.signal);
828
+ expect(mockSpawnRetry).toHaveBeenCalledTimes(2);
829
+ const { added, removed } = ctl.counts();
830
+ // Every _spawn attempt adds exactly one listener and removes it — no net leak.
831
+ expect(added).toBe(removed);
832
+ });
833
+
834
+ test("abort wins over a racing timeout: exit 143 classified as aborted, not a timeout/run error", async () => {
835
+ // The proc's kill() yields exit 143. Both the (very short) timeout and the
836
+ // abort race to kill it — but the aborted check must win, so the thrown
837
+ // error is ClaudeAbortedError, never ClaudeTimeoutError or ClaudeRunError.
838
+ const proc = hangingProc();
839
+ const mockSpawnRace = mock(
840
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
841
+ );
842
+ const runClaudeRace = createRunClaude(
843
+ mockSpawnRace as typeof Bun.spawn,
844
+ testSessions,
845
+ MODEL,
846
+ WORKSPACE,
847
+ fakeSentryClient,
848
+ undefined,
849
+ undefined,
850
+ undefined,
851
+ 5000, // ceiling far away
852
+ 5000, // idle far away — so only the abort fires
853
+ );
854
+
855
+ const ctl = countedSignal();
856
+ const { ClaudeAbortedError, ClaudeTimeoutError } = await import(
857
+ "./claude.ts"
858
+ );
859
+ const runPromise = runClaudeRace("hello", undefined, undefined, ctl.signal);
860
+ setTimeout(() => ctl.abort(), 5);
861
+ const err = await runPromise.catch((e) => e);
862
+ expect(err).toBeInstanceOf(ClaudeAbortedError);
863
+ expect(err).not.toBeInstanceOf(ClaudeTimeoutError);
864
+ });
865
+
866
+ test("an aborted run is NOT retried even when an existing session would otherwise be resumed", async () => {
867
+ const proc = hangingProc();
868
+ const mockSpawnAbortRetry = mock(
869
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
870
+ );
871
+
872
+ mockGetSession.mockClear();
873
+ mockGetSession.mockReturnValueOnce("existing-sid");
874
+
875
+ const runClaudeAbortRetry = createRunClaude(
876
+ mockSpawnAbortRetry as typeof Bun.spawn,
877
+ testSessions,
878
+ MODEL,
879
+ WORKSPACE,
880
+ fakeSentryClient,
881
+ );
882
+
883
+ const ctl = countedSignal();
884
+ const { ClaudeAbortedError } = await import("./claude.ts");
885
+ const runPromise = runClaudeAbortRetry(
886
+ "hello",
887
+ "chan:ts",
888
+ undefined,
889
+ ctl.signal,
890
+ );
891
+ setTimeout(() => ctl.abort(), 5);
892
+ const err = await runPromise.catch((e) => e);
893
+ expect(err).toBeInstanceOf(ClaudeAbortedError);
894
+ // Aborted runs skip the resume-retry path — spawn called exactly once.
895
+ expect(mockSpawnAbortRetry).toHaveBeenCalledTimes(1);
896
+ });
897
+
898
+ test("_saveSessionFromError persists a ClaudeAbortedError's session id so the session stays resumable", async () => {
899
+ // hangingProc emits a system/init line carrying a session id before abort.
900
+ const enc = new TextEncoder();
901
+ let ctrl!: ReadableStreamDefaultController<Uint8Array>;
902
+ let resolveExited!: (code: number) => void;
903
+ const exited = new Promise<number>((r) => {
904
+ resolveExited = r;
905
+ });
906
+ const proc = {
907
+ stdout: new ReadableStream<Uint8Array>({
908
+ start(c) {
909
+ ctrl = c;
910
+ c.enqueue(
911
+ enc.encode(
912
+ `${JSON.stringify({ type: "system", subtype: "init", session_id: "abort-sid" })}\n`,
913
+ ),
914
+ );
915
+ },
916
+ }),
917
+ stderr: bodyStream(""),
918
+ exited,
919
+ kill: () => {
920
+ ctrl.close();
921
+ resolveExited(143);
922
+ },
923
+ };
924
+ const mockSpawnAbortSave = mock(
925
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
926
+ );
927
+
928
+ mockSetSession.mockClear();
929
+ const runClaudeAbortSave = createRunClaude(
930
+ mockSpawnAbortSave as typeof Bun.spawn,
931
+ testSessions,
932
+ MODEL,
933
+ WORKSPACE,
934
+ fakeSentryClient,
935
+ undefined,
936
+ undefined,
937
+ undefined,
938
+ 5000,
939
+ 5000,
940
+ );
941
+
942
+ const ctl = countedSignal();
943
+ const { ClaudeAbortedError } = await import("./claude.ts");
944
+ const runPromise = runClaudeAbortSave(
945
+ "hello",
946
+ "chat:thread-1",
947
+ undefined,
948
+ ctl.signal,
949
+ );
950
+ setTimeout(() => ctl.abort(), 10);
951
+ const err = await runPromise.catch((e) => e);
952
+ expect(err).toBeInstanceOf(ClaudeAbortedError);
953
+ expect(mockSetSession).toHaveBeenCalledWith("chat:thread-1", "abort-sid");
954
+ });
955
+
956
+ test("idle-reset-on-activity: frequent stdout lines keep resetting the idle timer, so no timeout occurs even past the idle window", async () => {
957
+ // 6 lines at 15ms apart = 90ms total elapsed, well past the 20ms idle
958
+ // timeout if it were never reset — but each line resets it, so the
959
+ // idle timer never actually lapses. Ceiling (500ms) has ample headroom.
960
+ // The stream auto-closes right after the last line, so the run finishes
961
+ // cleanly (streamIncomplete: true — no result event) instead of hanging.
962
+ const proc = drippingProc(
963
+ JSON.stringify({ type: "system", subtype: "init" }),
964
+ 15,
965
+ 6,
966
+ true, // autoCloseAfterDrip
967
+ );
968
+ const mockSpawnActivity = mock(
969
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
970
+ );
971
+
972
+ const runClaudeWithIdleReset = createRunClaude(
973
+ mockSpawnActivity as typeof Bun.spawn,
974
+ testSessions,
975
+ MODEL,
976
+ WORKSPACE,
977
+ fakeSentryClient,
978
+ undefined,
979
+ undefined,
980
+ undefined,
981
+ 500, // 500ms ceiling — plenty of headroom
982
+ 20, // 20ms idle timeout — shorter than total elapsed time, but reset each 15ms line
983
+ );
984
+
985
+ const output = await runClaudeWithIdleReset("hello");
986
+ // Stream never got a `result` event but finished cleanly (no timeout) —
987
+ // proves the idle timer never fired despite total elapsed time exceeding
988
+ // its window, because each line kept resetting it.
989
+ expect(output.streamIncomplete).toBe(true);
990
+ });
991
+
992
+ test("idle-fires-despite-ceiling-headroom: stdout goes silent past the idle timeout well before the ceiling would fire", async () => {
993
+ const proc = hangingProc(); // never emits a line — silent from the start
994
+ const mockSpawnIdle = mock(
995
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
996
+ );
997
+
998
+ const runClaudeIdleOnly = createRunClaude(
999
+ mockSpawnIdle as typeof Bun.spawn,
1000
+ testSessions,
1001
+ MODEL,
1002
+ WORKSPACE,
1003
+ fakeSentryClient,
1004
+ undefined,
1005
+ undefined,
1006
+ undefined,
1007
+ 5000, // 5s ceiling — nowhere near firing in this test
1008
+ 15, // 15ms idle timeout — fires quickly since stdout is silent
1009
+ );
1010
+
1011
+ const { ClaudeTimeoutError } = await import("./claude.ts");
1012
+ const err = await runClaudeIdleOnly("hello").catch((e) => e);
1013
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
1014
+ const timeoutErr = err as InstanceType<typeof ClaudeTimeoutError>;
1015
+ expect(timeoutErr.reason).toBe("idle");
1016
+ // Must report the idle window (15ms) that actually fired, not the 5000ms
1017
+ // ceiling that never came close.
1018
+ expect(timeoutErr.timeoutMs).toBe(15);
1019
+ });
1020
+
1021
+ test("ceiling-fires-despite-continuous-activity: stdout lines keep resetting idle indefinitely, but total elapsed exceeds the ceiling", async () => {
1022
+ // Lines arrive every 5ms — always resetting the idle timer well before
1023
+ // its 30ms window lapses — but the process runs long enough (well past
1024
+ // 25ms) that the 25ms ceiling fires first regardless of activity.
1025
+ const proc = drippingProc(
1026
+ JSON.stringify({ type: "system", subtype: "init" }),
1027
+ 5,
1028
+ 50, // enough lines to keep the drip going past the ceiling
1029
+ );
1030
+
1031
+ const mockSpawnCeiling = mock(
1032
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
1033
+ );
1034
+
1035
+ const runClaudeCeiling = createRunClaude(
1036
+ mockSpawnCeiling as typeof Bun.spawn,
1037
+ testSessions,
1038
+ MODEL,
1039
+ WORKSPACE,
1040
+ fakeSentryClient,
1041
+ undefined,
1042
+ undefined,
1043
+ undefined,
1044
+ 25, // 25ms ceiling — fires despite continuous sub-idle-window activity
1045
+ 30, // 30ms idle timeout — never lapses since lines arrive every 5ms
1046
+ );
1047
+
1048
+ const { ClaudeTimeoutError } = await import("./claude.ts");
1049
+ const err = await runClaudeCeiling("hello").catch((e) => e);
1050
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
1051
+ expect((err as InstanceType<typeof ClaudeTimeoutError>).reason).toBe(
1052
+ "ceiling",
1053
+ );
1054
+ });
1055
+
1056
+ test("non-zero exit with JSON stdout throws ClaudeRunError with api_error_status", async () => {
1057
+ const json = JSON.stringify({
1058
+ type: "result",
1059
+ subtype: "error",
1060
+ result: "You've hit your org's monthly usage limit",
1061
+ session_id: "sess-x",
1062
+ is_error: true,
1063
+ api_error_status: 429,
1064
+ });
1065
+ mockSpawn.mockReturnValue(
1066
+ fakeProc(json, "", 1) as ReturnType<typeof Bun.spawn>,
1067
+ );
1068
+ const { ClaudeRunError } = await import("./claude.ts");
1069
+ try {
1070
+ await runClaude("hello");
1071
+ throw new Error("expected throw");
1072
+ } catch (err) {
1073
+ expect(err).toBeInstanceOf(ClaudeRunError);
1074
+ const e = err as InstanceType<typeof ClaudeRunError>;
1075
+ expect(e.apiErrorStatus).toBe(429);
1076
+ expect(e.resultMessage).toContain("monthly usage limit");
1077
+ }
1078
+ });
1079
+ });
1080
+
1081
+ // ─── Process-tree kill on timeout ─────────────────────────────────────────────
1082
+
1083
+ /**
1084
+ * Unlike every other test in this file (which injects a fully synthetic
1085
+ * FakeProc), these spawn a REAL subprocess through the same `spawner`
1086
+ * injection seam — because the behavior under test only exists at the OS
1087
+ * level: a `claude` CLI that forked its own children (an install, a build)
1088
+ * leaves those children running when only the direct child is signalled.
1089
+ * A fake proc can't express that.
1090
+ *
1091
+ * The stub script forks a long-lived grandchild, records its pid to a temp
1092
+ * file, and then waits forever without writing to stdout — so the ceiling
1093
+ * timer is what ends the run, exactly like a session that blew its budget.
1094
+ *
1095
+ * Without the fix these don't just leak a process, they hang: the orphaned
1096
+ * grandchild inherited the stdout pipe, so `_consumeStream` never sees EOF
1097
+ * and the run never settles at all. Both tests therefore fail (by timeout)
1098
+ * against a bare `proc.kill()`.
1099
+ *
1100
+ * Linux-only (procfs); self-skips elsewhere, matching the agent's runtime.
1101
+ */
1102
+ const canSpawnRealProcs =
1103
+ process.platform === "linux" && existsSync("/proc/self/task");
1104
+
1105
+ /** False for a pid that is gone OR an unreaped zombie (SIGKILLed orphan). */
1106
+ function pidIsAlive(pid: number): boolean {
1107
+ let stat: string;
1108
+ try {
1109
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
1110
+ } catch {
1111
+ return false;
1112
+ }
1113
+ const close = stat.lastIndexOf(")");
1114
+ return stat.slice(close + 2, close + 3) !== "Z";
1115
+ }
1116
+
1117
+ async function waitForPidDeath(
1118
+ pid: number,
1119
+ timeoutMs = 1500,
1120
+ ): Promise<boolean> {
1121
+ const deadline = Date.now() + timeoutMs;
1122
+ while (Date.now() < deadline) {
1123
+ if (!pidIsAlive(pid)) return true;
1124
+ await Bun.sleep(10);
1125
+ }
1126
+ return !pidIsAlive(pid);
1127
+ }
1128
+
1129
+ describe.if(canSpawnRealProcs)("process-tree kill on timeout", () => {
1130
+ test("a ceiling timeout kills the spawned CLI's forked descendant, not just the CLI itself", async () => {
1131
+ const dir = mkdtempSync(join(tmpdir(), "shipwright-treekill-"));
1132
+ const pidFile = join(dir, "grandchild.pid");
1133
+ const script = join(dir, "stub-claude.sh");
1134
+ // `sleep 300 &` stands in for an install/build the CLI kicked off; the
1135
+ // parent then blocks on `wait` and never writes to stdout, so only the
1136
+ // ceiling timer can end it.
1137
+ writeFileSync(
1138
+ script,
1139
+ `#!/bin/sh\nsleep 300 &\necho $! > "${pidFile}"\nwait\n`,
1140
+ );
1141
+
1142
+ // Thin wrapper around the real Bun.spawn: swaps the `claude` argv for the
1143
+ // stub script while passing every option through untouched (cwd, env, and
1144
+ // — critically — whatever cgroup option the implementation adds).
1145
+ const realSpawner = ((
1146
+ _cmd: string[],
1147
+ opts: Bun.SpawnOptions.OptionsObject<"ignore", "pipe", "pipe">,
1148
+ ) => Bun.spawn(["sh", script], opts)) as unknown as typeof Bun.spawn;
1149
+
1150
+ const runClaudeRealProc = createRunClaude(
1151
+ realSpawner,
1152
+ testSessions,
1153
+ MODEL,
1154
+ WORKSPACE,
1155
+ fakeSentryClient,
1156
+ undefined,
1157
+ undefined,
1158
+ undefined,
1159
+ 300, // 300ms ceiling — fires quickly, keeps the suite fast
1160
+ 5000, // idle far away: the ceiling is the trigger under test
1161
+ );
1162
+
1163
+ try {
1164
+ const err = await runClaudeRealProc("hello").catch((e) => e);
1165
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
1166
+ expect((err as InstanceType<typeof ClaudeTimeoutError>).reason).toBe(
1167
+ "ceiling",
1168
+ );
1169
+
1170
+ const grandchildPid = Number.parseInt(
1171
+ readFileSync(pidFile, "utf8").trim(),
1172
+ 10,
1173
+ );
1174
+ expect(Number.isInteger(grandchildPid)).toBe(true);
1175
+ // The whole point: before the process-tree kill, this grandchild was
1176
+ // reparented to init and kept running past the timeout.
1177
+ expect(await waitForPidDeath(grandchildPid)).toBe(true);
1178
+ } finally {
1179
+ rmSync(dir, { recursive: true, force: true });
1180
+ }
1181
+ });
1182
+
1183
+ test("an AbortSignal kills the spawned CLI's forked descendant too", async () => {
1184
+ const dir = mkdtempSync(join(tmpdir(), "shipwright-treekill-abort-"));
1185
+ const pidFile = join(dir, "grandchild.pid");
1186
+ const script = join(dir, "stub-claude.sh");
1187
+ writeFileSync(
1188
+ script,
1189
+ `#!/bin/sh\nsleep 300 &\necho $! > "${pidFile}"\nwait\n`,
1190
+ );
1191
+
1192
+ const realSpawner = ((
1193
+ _cmd: string[],
1194
+ opts: Bun.SpawnOptions.OptionsObject<"ignore", "pipe", "pipe">,
1195
+ ) => Bun.spawn(["sh", script], opts)) as unknown as typeof Bun.spawn;
1196
+
1197
+ const runClaudeRealProc = createRunClaude(
1198
+ realSpawner,
1199
+ testSessions,
1200
+ MODEL,
1201
+ WORKSPACE,
1202
+ fakeSentryClient,
1203
+ undefined,
1204
+ undefined,
1205
+ undefined,
1206
+ 10_000, // neither timer should fire — the abort is the trigger
1207
+ 10_000,
1208
+ );
1209
+
1210
+ try {
1211
+ const ctl = new AbortController();
1212
+ const runPromise = runClaudeRealProc(
1213
+ "hello",
1214
+ undefined,
1215
+ undefined,
1216
+ ctl.signal,
1217
+ );
1218
+ // Give the stub time to fork and record its grandchild pid.
1219
+ const deadline = Date.now() + 1500;
1220
+ while (!existsSync(pidFile) && Date.now() < deadline) await Bun.sleep(10);
1221
+ ctl.abort();
1222
+
1223
+ const { ClaudeAbortedError } = await import("./claude.ts");
1224
+ const err = await runPromise.catch((e) => e);
1225
+ expect(err).toBeInstanceOf(ClaudeAbortedError);
1226
+
1227
+ const grandchildPid = Number.parseInt(
1228
+ readFileSync(pidFile, "utf8").trim(),
1229
+ 10,
1230
+ );
1231
+ expect(await waitForPidDeath(grandchildPid)).toBe(true);
1232
+ } finally {
1233
+ rmSync(dir, { recursive: true, force: true });
1234
+ }
1235
+ });
1236
+ });
1237
+
1238
+ // ─── Stale session fallback tests ─────────────────────────────────────────────
1239
+
1240
+ describe("resume retry", () => {
1241
+ test("retries the same resumed session once when it fails, then succeeds", async () => {
1242
+ let callCount = 0;
1243
+ const mockSpawn = mock(() => {
1244
+ callCount++;
1245
+ if (callCount === 1) {
1246
+ // First call (resume) fails
1247
+ return fakeProc("", "socket closed", 1) as ReturnType<typeof Bun.spawn>;
1248
+ }
1249
+ // Second call (retry, same resumed session) succeeds
1250
+ return fakeProc(jsonOutput("recovered", "stale-sess-id")) as ReturnType<
1251
+ typeof Bun.spawn
1252
+ >;
1253
+ });
1254
+
1255
+ mockGetSession.mockClear();
1256
+ mockSetSession.mockClear();
1257
+ mockClearSession.mockClear();
1258
+ capturedMessages = [];
1259
+ capturedExceptions = [];
1260
+ mockGetSession.mockReturnValue("stale-sess-id");
1261
+
1262
+ const runClaude = createRunClaude(
1263
+ mockSpawn as typeof Bun.spawn,
1264
+ testSessions,
1265
+ MODEL,
1266
+ WORKSPACE,
1267
+ fakeSentryClient,
1268
+ );
1269
+
1270
+ const result = await runClaude("hello", "chan:ts");
1271
+
1272
+ expect(result.result).toBe("recovered");
1273
+ expect(result.sessionId).toBe("stale-sess-id");
1274
+ expect(result.recoveredFromError).toBe(true);
1275
+ expect(mockClearSession).not.toHaveBeenCalled();
1276
+ expect(mockSetSession).toHaveBeenCalledWith("chan:ts", "stale-sess-id");
1277
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1278
+
1279
+ // Verify both calls resumed the SAME session id via -r
1280
+ const [firstCmd] = mockSpawn.mock.calls[0] as unknown as [string[]];
1281
+ const [secondCmd] = mockSpawn.mock.calls[1] as unknown as [string[]];
1282
+ const firstRIdx = firstCmd.indexOf("-r");
1283
+ const secondRIdx = secondCmd.indexOf("-r");
1284
+ expect(firstRIdx).toBeGreaterThan(-1);
1285
+ expect(secondRIdx).toBeGreaterThan(-1);
1286
+ expect(firstCmd[firstRIdx + 1]).toBe("stale-sess-id");
1287
+ expect(secondCmd[secondRIdx + 1]).toBe("stale-sess-id");
1288
+ });
1289
+
1290
+ test("passes extraEnv to both the initial attempt and the resume-retry attempt", async () => {
1291
+ let callCount = 0;
1292
+ const mockSpawn = mock(() => {
1293
+ callCount++;
1294
+ if (callCount === 1) {
1295
+ // First call (resume) fails
1296
+ return fakeProc("", "socket closed", 1) as ReturnType<typeof Bun.spawn>;
1297
+ }
1298
+ // Second call (retry, same resumed session) succeeds
1299
+ return fakeProc(jsonOutput("recovered", "stale-sess-id")) as ReturnType<
1300
+ typeof Bun.spawn
1301
+ >;
1302
+ });
1303
+
1304
+ mockGetSession.mockClear();
1305
+ mockSetSession.mockClear();
1306
+ mockClearSession.mockClear();
1307
+ capturedMessages = [];
1308
+ capturedExceptions = [];
1309
+ mockGetSession.mockReturnValue("stale-sess-id");
1310
+
1311
+ const runClaude = createRunClaude(
1312
+ mockSpawn as typeof Bun.spawn,
1313
+ testSessions,
1314
+ MODEL,
1315
+ WORKSPACE,
1316
+ fakeSentryClient,
1317
+ );
1318
+
1319
+ await runClaude("hello", "chan:ts", undefined, undefined, {
1320
+ MY_VAR: "retry-value",
1321
+ });
1322
+
1323
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1324
+ const [, firstOpts] = mockSpawn.mock.calls[0] as unknown as [
1325
+ string[],
1326
+ { env: Record<string, string | undefined> },
1327
+ ];
1328
+ const [, secondOpts] = mockSpawn.mock.calls[1] as unknown as [
1329
+ string[],
1330
+ { env: Record<string, string | undefined> },
1331
+ ];
1332
+ expect(firstOpts.env.MY_VAR).toBe("retry-value");
1333
+ expect(secondOpts.env.MY_VAR).toBe("retry-value");
1334
+ });
1335
+
1336
+ test("per-call onProgress still fires on the second _spawn attempt during a resume-retry", async () => {
1337
+ let callCount = 0;
1338
+ const mockSpawn = mock(() => {
1339
+ callCount++;
1340
+ if (callCount === 1) {
1341
+ // First call (resume) fails
1342
+ return fakeProc("", "socket closed", 1) as ReturnType<typeof Bun.spawn>;
1343
+ }
1344
+ // Second call (retry, same resumed session) succeeds and streams
1345
+ // stream-json turns, so per-call onProgress has something to fire on.
1346
+ return ndjsonProc(cleanMultiTurn.lines) as ReturnType<typeof Bun.spawn>;
1347
+ });
1348
+
1349
+ mockGetSession.mockClear();
1350
+ mockSetSession.mockClear();
1351
+ mockClearSession.mockClear();
1352
+ capturedMessages = [];
1353
+ capturedExceptions = [];
1354
+ mockGetSession.mockReturnValue("stale-sess-id");
1355
+
1356
+ const runClaude = createRunClaude(
1357
+ mockSpawn as typeof Bun.spawn,
1358
+ testSessions,
1359
+ MODEL,
1360
+ WORKSPACE,
1361
+ fakeSentryClient,
1362
+ );
1363
+
1364
+ const progress: Array<Record<string, unknown>> = [];
1365
+ const result = await runClaude("hello", "chan:ts", (mu, phase) => {
1366
+ if (phase === undefined) progress.push(structuredClone(mu));
1367
+ });
1368
+
1369
+ expect(result.recoveredFromError).toBe(true);
1370
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1371
+ // Progress must have fired during the retried (second) _spawn attempt.
1372
+ expect(progress).toHaveLength(cleanMultiTurn.expectedProgressCount);
1373
+ expect(progress[progress.length - 1]).toEqual(
1374
+ cleanMultiTurn.expectedAccumulated,
1375
+ );
1376
+ });
1377
+
1378
+ test("does not retry when there is no existing session (fresh call fails)", async () => {
1379
+ const mockSpawn = mock(
1380
+ () => fakeProc("", "auth failure", 1) as ReturnType<typeof Bun.spawn>,
1381
+ );
1382
+
1383
+ mockGetSession.mockClear();
1384
+ mockGetSession.mockReturnValue(undefined);
1385
+
1386
+ const runClaude = createRunClaude(
1387
+ mockSpawn as typeof Bun.spawn,
1388
+ testSessions,
1389
+ MODEL,
1390
+ WORKSPACE,
1391
+ );
1392
+
1393
+ await expect(runClaude("hello")).rejects.toThrow("auth failure");
1394
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
1395
+ });
1396
+
1397
+ test("throws if both resume attempts fail, preserving the session mapping", async () => {
1398
+ const mockSpawn = mock(
1399
+ () => fakeProc("", "total failure", 1) as ReturnType<typeof Bun.spawn>,
1400
+ );
1401
+
1402
+ // Stateful in-memory session store double — NOT the module-level spy mocks.
1403
+ const store = new Map<string, string>([["chan:ts", "stale-sess"]]);
1404
+ const clearSpy = mock((_key: string): void => {});
1405
+ const statefulSessions = {
1406
+ get: (key: string) => store.get(key),
1407
+ set: (key: string, id: string) => {
1408
+ store.set(key, id);
1409
+ },
1410
+ clear: clearSpy,
1411
+ };
1412
+
1413
+ const runClaude = createRunClaude(
1414
+ mockSpawn as typeof Bun.spawn,
1415
+ statefulSessions,
1416
+ MODEL,
1417
+ WORKSPACE,
1418
+ fakeSentryClient,
1419
+ );
1420
+
1421
+ await expect(runClaude("hello", "chan:ts")).rejects.toThrow(
1422
+ "total failure",
1423
+ );
1424
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1425
+ expect(clearSpy).not.toHaveBeenCalled();
1426
+ // The mapping must still point at the original session id.
1427
+ expect(store.get("chan:ts")).toBe("stale-sess");
1428
+ // Retry-exhausted case is reported to Sentry with the ORIGINAL error.
1429
+ expect(capturedExceptions).toHaveLength(1);
1430
+ expect((capturedExceptions[0] as Error).message).toContain("total failure");
1431
+ });
1432
+
1433
+ test("does not report to Sentry when the retry succeeds", async () => {
1434
+ let callCount = 0;
1435
+ const mockSpawn = mock(() => {
1436
+ callCount++;
1437
+ if (callCount === 1) {
1438
+ return fakeProc("", "socket closed", 1) as ReturnType<typeof Bun.spawn>;
1439
+ }
1440
+ return fakeProc(jsonOutput("recovered", "stale-sess-id")) as ReturnType<
1441
+ typeof Bun.spawn
1442
+ >;
1443
+ });
1444
+
1445
+ mockGetSession.mockClear();
1446
+ mockSetSession.mockClear();
1447
+ mockClearSession.mockClear();
1448
+ capturedExceptions = [];
1449
+ mockGetSession.mockReturnValue("stale-sess-id");
1450
+
1451
+ const runClaude = createRunClaude(
1452
+ mockSpawn as typeof Bun.spawn,
1453
+ testSessions,
1454
+ MODEL,
1455
+ WORKSPACE,
1456
+ fakeSentryClient,
1457
+ );
1458
+
1459
+ const result = await runClaude("hello", "chan:ts");
1460
+
1461
+ expect(result.recoveredFromError).toBe(true);
1462
+ expect(capturedExceptions).toHaveLength(0);
1463
+ });
1464
+ });
1465
+
1466
+ // ─── Connection-lost-mid-response retry (error-7616867192) ────────────────────
1467
+ //
1468
+ // A transient network drop between the Claude Code CLI and the Anthropic API
1469
+ // mid-response throws a ClaudeRunError whose result text contains "Connection
1470
+ // lost mid-response" (Sentry issue 7616867192 / SENTRY-47). Per human
1471
+ // decision (Dave, 2026-09-08), `_spawn` retries this specific failure exactly
1472
+ // once — discarding the partial response and re-running the whole spawn from
1473
+ // scratch — regardless of whether a session exists to resume. This is
1474
+ // independent of (and orthogonal to) the session-resume retry above.
1475
+
1476
+ function connectionLostJson(sessionId = "sess-cl"): string {
1477
+ return JSON.stringify({
1478
+ type: "result",
1479
+ subtype: "error",
1480
+ result:
1481
+ "API Error: Connection lost mid-response. The response above may be incomplete.",
1482
+ session_id: sessionId,
1483
+ is_error: true,
1484
+ });
1485
+ }
1486
+
1487
+ describe("connection-lost-mid-response retry", () => {
1488
+ test("retries once even with no existing session to resume, then succeeds", async () => {
1489
+ let callCount = 0;
1490
+ const mockSpawn = mock(() => {
1491
+ callCount++;
1492
+ if (callCount === 1) {
1493
+ return fakeProc(connectionLostJson(), "", 1) as ReturnType<
1494
+ typeof Bun.spawn
1495
+ >;
1496
+ }
1497
+ return fakeProc(jsonOutput("recovered", "sess-cl")) as ReturnType<
1498
+ typeof Bun.spawn
1499
+ >;
1500
+ });
1501
+
1502
+ mockGetSession.mockClear();
1503
+ mockGetSession.mockReturnValue(undefined);
1504
+ capturedMessages = [];
1505
+ capturedExceptions = [];
1506
+
1507
+ const runClaude = createRunClaude(
1508
+ mockSpawn as typeof Bun.spawn,
1509
+ testSessions,
1510
+ MODEL,
1511
+ WORKSPACE,
1512
+ fakeSentryClient,
1513
+ );
1514
+
1515
+ const result = await runClaude("hello");
1516
+
1517
+ expect(result.result).toBe("recovered");
1518
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1519
+ expect(capturedMessages).toHaveLength(1);
1520
+ expect(capturedMessages[0]).toContain("Connection lost mid-response");
1521
+ expect(capturedExceptions).toHaveLength(0);
1522
+ });
1523
+
1524
+ test("does not retry a second time when the retry also hits connection-lost-mid-response", async () => {
1525
+ const mockSpawn = mock(
1526
+ () =>
1527
+ fakeProc(connectionLostJson(), "", 1) as ReturnType<typeof Bun.spawn>,
1528
+ );
1529
+
1530
+ mockGetSession.mockClear();
1531
+ mockGetSession.mockReturnValue(undefined);
1532
+ capturedMessages = [];
1533
+ capturedExceptions = [];
1534
+
1535
+ const { ClaudeRunError } = await import("./claude.ts");
1536
+ const runClaude = createRunClaude(
1537
+ mockSpawn as typeof Bun.spawn,
1538
+ testSessions,
1539
+ MODEL,
1540
+ WORKSPACE,
1541
+ fakeSentryClient,
1542
+ );
1543
+
1544
+ await expect(runClaude("hello")).rejects.toBeInstanceOf(ClaudeRunError);
1545
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1546
+ // Logged once, for the one retry attempt — not on the second failure.
1547
+ expect(capturedMessages).toHaveLength(1);
1548
+ });
1549
+
1550
+ test("an unrelated is_error failure is NOT retried via _spawn's own connection-lost mechanism, but IS retried once via the widened outer gate (it carries a session id)", async () => {
1551
+ // This failure is not connection-lost-mid-response, so `_spawn`'s own
1552
+ // dedicated single retry (and its Sentry captureMessage) never fires —
1553
+ // capturedMessages stays empty throughout. But per the widened retry
1554
+ // gate (see `_runClaude`), ANY ClaudeRunError that carries a session id
1555
+ // (this one does: result.session_id = "sess-x") is now eligible for
1556
+ // _runClaude's own one-shot outer retry, even on a fresh call with no
1557
+ // existingSessionId — this is the acceptance-criterion behavior, not a
1558
+ // connection-lost special case.
1559
+ const mockSpawn = mock(
1560
+ () =>
1561
+ fakeProc(
1562
+ jsonOutput(
1563
+ "You've hit your org's monthly usage limit",
1564
+ "sess-x",
1565
+ true,
1566
+ ),
1567
+ "",
1568
+ 1,
1569
+ ) as ReturnType<typeof Bun.spawn>,
1570
+ );
1571
+
1572
+ mockGetSession.mockClear();
1573
+ mockGetSession.mockReturnValue(undefined);
1574
+ capturedMessages = [];
1575
+ capturedExceptions = [];
1576
+
1577
+ const runClaude = createRunClaude(
1578
+ mockSpawn as typeof Bun.spawn,
1579
+ testSessions,
1580
+ MODEL,
1581
+ WORKSPACE,
1582
+ fakeSentryClient,
1583
+ );
1584
+
1585
+ await expect(runClaude("hello")).rejects.toThrow("monthly usage limit");
1586
+ // Outer gate retried once (both attempts fail identically) — one retry
1587
+ // only, matching the existing one-retry-only contract.
1588
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
1589
+ // The retry used -r sess-x, the session id carried by the error.
1590
+ const [secondCmd] = mockSpawn.mock.calls[1] as unknown as [string[]];
1591
+ const rIdx = secondCmd.indexOf("-r");
1592
+ expect(rIdx).toBeGreaterThan(-1);
1593
+ expect(secondCmd[rIdx + 1]).toBe("sess-x");
1594
+ // No connection-lost captureMessage — this path is unrelated to that
1595
+ // mechanism.
1596
+ expect(capturedMessages).toHaveLength(0);
1597
+ // The exhausted-retry Sentry capture still fires with the ORIGINAL error.
1598
+ expect(capturedExceptions).toHaveLength(1);
1599
+ });
1600
+ });
1601
+
1602
+ // ─── Session persistence on failure (CSI-1.2) ─────────────────────────────────
1603
+ //
1604
+ // _saveSession only ran after a successful spawn, so a first-message timeout
1605
+ // or error never persisted the session id the CLI had already created —
1606
+ // leaving the Slack thread unable to resume. These tests assert the failure
1607
+ // path now saves whatever session id was captured, even though the run
1608
+ // itself still throws.
1609
+
1610
+ describe("session persistence on failure", () => {
1611
+ test("immediate timeout with no existing session still saves the captured session id", async () => {
1612
+ // hangingProc never emits a line, so idle timeout fires with no
1613
+ // earlySessionId captured off a `system`/`init` line either — this test
1614
+ // uses a dripping proc that emits exactly one init line (carrying a
1615
+ // session id) before going silent, so the idle timer still fires but a
1616
+ // sessionId is available on the resulting ClaudeTimeoutError.
1617
+ const proc = drippingProc(
1618
+ JSON.stringify({
1619
+ type: "system",
1620
+ subtype: "init",
1621
+ session_id: "fresh-sess-id",
1622
+ }),
1623
+ 1000, // only fires once at start via the drip loop's initial enqueue+wait
1624
+ 1,
1625
+ );
1626
+ const mockSpawnTimeout = mock(
1627
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
1628
+ );
1629
+
1630
+ mockGetSession.mockClear();
1631
+ mockSetSession.mockClear();
1632
+ mockGetSession.mockReturnValue(undefined);
1633
+
1634
+ const runClaudeWithTimeout = createRunClaude(
1635
+ mockSpawnTimeout as typeof Bun.spawn,
1636
+ testSessions,
1637
+ MODEL,
1638
+ WORKSPACE,
1639
+ fakeSentryClient,
1640
+ undefined,
1641
+ undefined,
1642
+ undefined,
1643
+ 5000, // ceiling — nowhere near firing
1644
+ 15, // idle timeout — fires quickly since stdout only emits the one line
1645
+ );
1646
+
1647
+ const { ClaudeTimeoutError } = await import("./claude.ts");
1648
+ const err = await runClaudeWithTimeout("hello", "chan:ts").catch((e) => e);
1649
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
1650
+ expect((err as InstanceType<typeof ClaudeTimeoutError>).sessionId).toBe(
1651
+ "fresh-sess-id",
1652
+ );
1653
+ // No existing session, so no retry happens — the outer catch must save
1654
+ // the session id from the initial attempt's failure directly.
1655
+ expect(mockSetSession).toHaveBeenCalledWith("chan:ts", "fresh-sess-id");
1656
+ });
1657
+
1658
+ test("resumed-session retry that also times out saves the most-recently-known session id", async () => {
1659
+ let callCount = 0;
1660
+ const mockSpawnRetry = mock(() => {
1661
+ callCount++;
1662
+ if (callCount === 1) {
1663
+ // First call (resume) fails with a non-timeout error — enters the
1664
+ // retry branch.
1665
+ return fakeProc("", "socket closed", 1) as ReturnType<typeof Bun.spawn>;
1666
+ }
1667
+ // Retry attempt hangs and times out, but still captures a session id
1668
+ // off its own init line before the idle timer kills it.
1669
+ return drippingProc(
1670
+ JSON.stringify({
1671
+ type: "system",
1672
+ subtype: "init",
1673
+ session_id: "retry-sess-id",
1674
+ }),
1675
+ 1000,
1676
+ 1,
1677
+ ) as unknown as ReturnType<typeof Bun.spawn>;
1678
+ });
1679
+
1680
+ mockGetSession.mockClear();
1681
+ mockSetSession.mockClear();
1682
+ mockGetSession.mockReturnValue("stale-sess-id");
1683
+
1684
+ const runClaudeWithRetryTimeout = createRunClaude(
1685
+ mockSpawnRetry as typeof Bun.spawn,
1686
+ testSessions,
1687
+ MODEL,
1688
+ WORKSPACE,
1689
+ fakeSentryClient,
1690
+ undefined,
1691
+ undefined,
1692
+ undefined,
1693
+ 5000, // ceiling — nowhere near firing
1694
+ 15, // idle timeout — fires quickly on the retry's silent stdout
1695
+ );
1696
+
1697
+ const err = await runClaudeWithRetryTimeout("hello", "chan:ts").catch(
1698
+ (e) => e,
1699
+ );
1700
+ // The retry contract rethrows the ORIGINAL (first-attempt) error
1701
+ // unchanged, even though the retry itself failed with a timeout — only
1702
+ // the session-persistence side effect changes.
1703
+ expect(err).toBeInstanceOf(Error);
1704
+ expect((err as Error).message).toContain("socket closed");
1705
+ expect(mockSpawnRetry).toHaveBeenCalledTimes(2);
1706
+ // The mapping must point at the retry's session id — the most recently
1707
+ // known one — not the original stale session id.
1708
+ expect(mockSetSession).toHaveBeenCalledWith("chan:ts", "retry-sess-id");
1709
+ expect(mockSetSession).toHaveBeenLastCalledWith("chan:ts", "retry-sess-id");
1710
+ });
1711
+
1712
+ test("does not save when no sessionId was ever captured (process crashes before any stdout)", async () => {
1713
+ const mockSpawnCrash = mock(
1714
+ () => fakeProc("", "segfault", 139) as ReturnType<typeof Bun.spawn>,
1715
+ );
1716
+
1717
+ mockGetSession.mockClear();
1718
+ mockSetSession.mockClear();
1719
+ mockGetSession.mockReturnValue(undefined);
1720
+
1721
+ const runClaudeCrash = createRunClaude(
1722
+ mockSpawnCrash as typeof Bun.spawn,
1723
+ testSessions,
1724
+ MODEL,
1725
+ WORKSPACE,
1726
+ fakeSentryClient,
1727
+ );
1728
+
1729
+ await expect(runClaudeCrash("hello", "chan:ts")).rejects.toThrow(
1730
+ "segfault",
1731
+ );
1732
+ expect(mockSetSession).not.toHaveBeenCalled();
1733
+ });
1734
+
1735
+ test("a session store write failure on the initial-attempt catch does not mask the original error", async () => {
1736
+ // No existing session, so no retry happens — this exercises the
1737
+ // initial `catch (err)` call site's guard directly.
1738
+ const proc = drippingProc(
1739
+ JSON.stringify({
1740
+ type: "system",
1741
+ subtype: "init",
1742
+ session_id: "fresh-sess-id",
1743
+ }),
1744
+ 1000,
1745
+ 1,
1746
+ );
1747
+ const mockSpawnTimeout = mock(
1748
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
1749
+ );
1750
+
1751
+ const throwingSetSession = mock((_key: string, _id: string) => {
1752
+ throw new Error("disk full");
1753
+ });
1754
+ const throwingSessions = {
1755
+ get: (_key: string): string | undefined => undefined,
1756
+ set: throwingSetSession,
1757
+ clear: (_key: string) => {},
1758
+ };
1759
+
1760
+ const runClaudeWithTimeout = createRunClaude(
1761
+ mockSpawnTimeout as typeof Bun.spawn,
1762
+ throwingSessions,
1763
+ MODEL,
1764
+ WORKSPACE,
1765
+ fakeSentryClient,
1766
+ undefined,
1767
+ undefined,
1768
+ undefined,
1769
+ 5000, // ceiling — nowhere near firing
1770
+ 15, // idle timeout — fires quickly since stdout only emits the one line
1771
+ );
1772
+
1773
+ const { ClaudeTimeoutError } = await import("./claude.ts");
1774
+ const err = await runClaudeWithTimeout("hello", "chan:ts").catch((e) => e);
1775
+ // The persistence failure must never replace the original timeout error.
1776
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
1777
+ expect((err as InstanceType<typeof ClaudeTimeoutError>).sessionId).toBe(
1778
+ "fresh-sess-id",
1779
+ );
1780
+ expect(throwingSetSession).toHaveBeenCalledWith("chan:ts", "fresh-sess-id");
1781
+ });
1782
+
1783
+ test("a session store write failure on the retry catch does not mask the original error or skip Sentry capture", async () => {
1784
+ let callCount = 0;
1785
+ const mockSpawnRetry = mock(() => {
1786
+ callCount++;
1787
+ if (callCount === 1) {
1788
+ // First call (resume) fails with a non-timeout error — enters the
1789
+ // retry branch.
1790
+ return fakeProc("", "socket closed", 1) as ReturnType<typeof Bun.spawn>;
1791
+ }
1792
+ // Retry attempt hangs and times out, but still captures a session id
1793
+ // off its own init line before the idle timer kills it.
1794
+ return drippingProc(
1795
+ JSON.stringify({
1796
+ type: "system",
1797
+ subtype: "init",
1798
+ session_id: "retry-sess-id",
1799
+ }),
1800
+ 1000,
1801
+ 1,
1802
+ ) as unknown as ReturnType<typeof Bun.spawn>;
1803
+ });
1804
+
1805
+ const throwingSetSession = mock((_key: string, _id: string) => {
1806
+ throw new Error("disk full");
1807
+ });
1808
+ const throwingSessions = {
1809
+ get: (_key: string): string | undefined => "stale-sess-id",
1810
+ set: throwingSetSession,
1811
+ clear: (_key: string) => {},
1812
+ };
1813
+
1814
+ capturedExceptions = [];
1815
+ const runClaudeWithRetryTimeout = createRunClaude(
1816
+ mockSpawnRetry as typeof Bun.spawn,
1817
+ throwingSessions,
1818
+ MODEL,
1819
+ WORKSPACE,
1820
+ fakeSentryClient,
1821
+ undefined,
1822
+ undefined,
1823
+ undefined,
1824
+ 5000, // ceiling — nowhere near firing
1825
+ 15, // idle timeout — fires quickly on the retry's silent stdout
1826
+ );
1827
+
1828
+ const err = await runClaudeWithRetryTimeout("hello", "chan:ts").catch(
1829
+ (e) => e,
1830
+ );
1831
+ // The retry contract rethrows the ORIGINAL (first-attempt) error
1832
+ // unchanged, even though the retry's own session-persistence write
1833
+ // threw.
1834
+ expect(err).toBeInstanceOf(Error);
1835
+ expect((err as Error).message).toContain("socket closed");
1836
+ expect(mockSpawnRetry).toHaveBeenCalledTimes(2);
1837
+ expect(throwingSetSession).toHaveBeenCalledWith("chan:ts", "retry-sess-id");
1838
+ // Sentry capture of the ORIGINAL error must still run despite the
1839
+ // persistence failure.
1840
+ expect(capturedExceptions).toHaveLength(1);
1841
+ expect((capturedExceptions[0] as Error).message).toContain("socket closed");
1842
+ });
1843
+
1844
+ test("saves the captured session id immediately on the init line, before the run settles (ESP-1.1)", async () => {
1845
+ // Simulates the orchestrator process itself dying mid-run (OOM, pod
1846
+ // restart, redeploy) — not just the spawned `claude` CLI child being
1847
+ // killed. If persistence only happens at settle time (via _saveSession /
1848
+ // _saveSessionFromError), a process death between the init line and the
1849
+ // run settling loses the session id even though the CLI session itself
1850
+ // is already durably resumable. `autoCloseAfterDrip: false` keeps
1851
+ // `exited` pending indefinitely — nothing settles until we `.kill()` it
1852
+ // below — so this test can only pass if the write happens off the init
1853
+ // line itself, not off any catch/finally.
1854
+ const intervalMs = 20;
1855
+ const proc = drippingProc(
1856
+ JSON.stringify({
1857
+ type: "system",
1858
+ subtype: "init",
1859
+ session_id: "early-write-sess-id",
1860
+ }),
1861
+ intervalMs,
1862
+ 3, // several drips — exited must stay pending well past the first one
1863
+ false, // never auto-closes; only kill() (called below) settles it
1864
+ );
1865
+ const mockSpawnEarly = mock(
1866
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
1867
+ );
1868
+
1869
+ mockGetSession.mockClear();
1870
+ mockSetSession.mockClear();
1871
+ mockGetSession.mockReturnValue(undefined);
1872
+
1873
+ const runClaudeEarly = createRunClaude(
1874
+ mockSpawnEarly as typeof Bun.spawn,
1875
+ testSessions,
1876
+ MODEL,
1877
+ WORKSPACE,
1878
+ fakeSentryClient,
1879
+ );
1880
+
1881
+ // Deliberately NOT awaited yet — the run must still be in flight when we
1882
+ // assert below.
1883
+ const pending = runClaudeEarly("hello", "chan:ts");
1884
+
1885
+ // Wait just long enough for the first drip (the init line) to land, but
1886
+ // nowhere near long enough for the process to settle (it never will,
1887
+ // short of the kill() below).
1888
+ await new Promise((r) => setTimeout(r, intervalMs + 30));
1889
+
1890
+ expect(mockSetSession).toHaveBeenCalledWith(
1891
+ "chan:ts",
1892
+ "early-write-sess-id",
1893
+ );
1894
+
1895
+ // Clean up: kill the dripping proc so `exited` resolves, then let the
1896
+ // pending run settle (successfully or not — irrelevant here) so no
1897
+ // dangling timer/promise leaks into later tests.
1898
+ proc.kill();
1899
+ await pending.catch(() => {});
1900
+ });
1901
+ });
1902
+
1903
+ // ─── _enqueue serialization tests ────────────────────────────────────────────
1904
+
1905
+ describe("_enqueue (via runClaude with sessionKey)", () => {
1906
+ beforeEach(() => {
1907
+ mockGetSession.mockReset();
1908
+ mockSetSession.mockReset();
1909
+ mockClearSession.mockReset();
1910
+ });
1911
+
1912
+ test("second call with same key waits for first to complete before spawning", async () => {
1913
+ let spawnCount = 0;
1914
+ let resolveFirst!: (code: number) => void;
1915
+ const firstExited = new Promise<number>((r) => {
1916
+ resolveFirst = r;
1917
+ });
1918
+
1919
+ const mockSpawn = mock(() => {
1920
+ spawnCount++;
1921
+ const exited = spawnCount === 1 ? firstExited : Promise.resolve(0);
1922
+ return {
1923
+ stdout: bodyStream(jsonOutput(`result-${spawnCount}`)),
1924
+ stderr: bodyStream(""),
1925
+ exited,
1926
+ } as ReturnType<typeof Bun.spawn>;
1927
+ });
1928
+ const runClaude = createRunClaude(
1929
+ mockSpawn as typeof Bun.spawn,
1930
+ testSessions,
1931
+ MODEL,
1932
+ WORKSPACE,
1933
+ );
1934
+
1935
+ const p1 = runClaude("msg1", "serial-key");
1936
+ const p2 = runClaude("msg2", "serial-key");
1937
+
1938
+ await new Promise((r) => setTimeout(r, 0));
1939
+ expect(spawnCount).toBe(1);
1940
+
1941
+ resolveFirst(0);
1942
+ await p1;
1943
+
1944
+ await new Promise((r) => setTimeout(r, 0));
1945
+ expect(spawnCount).toBe(2);
1946
+
1947
+ await p2;
1948
+ });
1949
+
1950
+ test("calls with different keys run concurrently (not serialized)", async () => {
1951
+ const mockSpawn = mock(() => {
1952
+ return {
1953
+ stdout: bodyStream(jsonOutput("ok")),
1954
+ stderr: bodyStream(""),
1955
+ exited: Promise.resolve(0),
1956
+ } as ReturnType<typeof Bun.spawn>;
1957
+ });
1958
+ const runClaude = createRunClaude(
1959
+ mockSpawn as typeof Bun.spawn,
1960
+ testSessions,
1961
+ MODEL,
1962
+ WORKSPACE,
1963
+ );
1964
+
1965
+ const [r1, r2] = await Promise.all([
1966
+ runClaude("msg1", "key-a"),
1967
+ runClaude("msg2", "key-b"),
1968
+ ]);
1969
+
1970
+ expect(r1.result).toBe("ok");
1971
+ expect(r2.result).toBe("ok");
1972
+ });
1973
+
1974
+ test("second call still runs after first call throws", async () => {
1975
+ let callCount = 0;
1976
+ const mockSpawn = mock(() => {
1977
+ callCount++;
1978
+ const exitCode = callCount === 1 ? 1 : 0;
1979
+ return {
1980
+ stdout: bodyStream(callCount === 1 ? "" : jsonOutput("recovered")),
1981
+ stderr: bodyStream(callCount === 1 ? "error on first" : ""),
1982
+ exited: Promise.resolve(exitCode),
1983
+ } as ReturnType<typeof Bun.spawn>;
1984
+ });
1985
+ const runClaude = createRunClaude(
1986
+ mockSpawn as typeof Bun.spawn,
1987
+ testSessions,
1988
+ MODEL,
1989
+ WORKSPACE,
1990
+ );
1991
+
1992
+ const p1 = runClaude("msg1", "error-key");
1993
+ const p2 = runClaude("msg2", "error-key");
1994
+
1995
+ await expect(p1).rejects.toThrow();
1996
+ const r2 = await p2;
1997
+ expect(r2.result).toBe("recovered");
1998
+ });
1999
+ });
2000
+
2001
+ // ─── extraAllowedTools tests ──────────────────────────────────────────────────
2002
+
2003
+ describe("extraAllowedTools", () => {
2004
+ // biome-ignore lint/suspicious/noExplicitAny: mock return type
2005
+ let mockSpawn: any;
2006
+
2007
+ // Extracts the tool names listed after --allowedTools, up to the next flag
2008
+ function extractAllowedTools(cmd: string[]): string[] {
2009
+ const allowedIdx = cmd.indexOf("--allowedTools");
2010
+ const tools: string[] = [];
2011
+ for (let i = allowedIdx + 1; i < cmd.length; i++) {
2012
+ if (cmd[i].startsWith("-")) break;
2013
+ tools.push(cmd[i]);
2014
+ }
2015
+ return tools;
2016
+ }
2017
+
2018
+ beforeEach(() => {
2019
+ mockGetSession.mockClear();
2020
+ mockSetSession.mockClear();
2021
+ mockClearSession.mockClear();
2022
+ capturedMessages = [];
2023
+ capturedExceptions = [];
2024
+ mockSpawn = mock(
2025
+ () =>
2026
+ fakeProc(jsonOutput("Hello from Claude")) as ReturnType<
2027
+ typeof Bun.spawn
2028
+ >,
2029
+ );
2030
+ });
2031
+
2032
+ test("empty extraAllowedTools produces identical args to no extraAllowedTools", async () => {
2033
+ const runClaudeDefault = createRunClaude(
2034
+ mockSpawn as typeof Bun.spawn,
2035
+ testSessions,
2036
+ MODEL,
2037
+ WORKSPACE,
2038
+ fakeSentryClient,
2039
+ );
2040
+ const runClaudeEmpty = createRunClaude(
2041
+ mockSpawn as typeof Bun.spawn,
2042
+ testSessions,
2043
+ MODEL,
2044
+ WORKSPACE,
2045
+ fakeSentryClient,
2046
+ [],
2047
+ );
2048
+
2049
+ await runClaudeDefault("test");
2050
+ const [defaultCmd] = mockSpawn.mock.calls[0] as [string[]];
2051
+
2052
+ mockSpawn.mockClear();
2053
+
2054
+ await runClaudeEmpty("test");
2055
+ const [emptyCmd] = mockSpawn.mock.calls[0] as [string[]];
2056
+
2057
+ expect(emptyCmd).toEqual(defaultCmd);
2058
+ });
2059
+
2060
+ test("non-empty extraAllowedTools are appended after existing tools in --allowedTools", async () => {
2061
+ const runClaude = createRunClaude(
2062
+ mockSpawn as typeof Bun.spawn,
2063
+ testSessions,
2064
+ MODEL,
2065
+ WORKSPACE,
2066
+ fakeSentryClient,
2067
+ ["mcp__my_server__my_tool", "mcp__other__tool"],
2068
+ );
2069
+
2070
+ await runClaude("test");
2071
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2072
+
2073
+ expect(cmd.indexOf("--allowedTools")).toBeGreaterThan(-1);
2074
+ const toolsInCmd = extractAllowedTools(cmd);
2075
+
2076
+ expect(toolsInCmd).toContain("mcp__my_server__my_tool");
2077
+ expect(toolsInCmd).toContain("mcp__other__tool");
2078
+ });
2079
+
2080
+ test("extraAllowedTools appear after built-in tools (not before)", async () => {
2081
+ const runClaude = createRunClaude(
2082
+ mockSpawn as typeof Bun.spawn,
2083
+ testSessions,
2084
+ MODEL,
2085
+ WORKSPACE,
2086
+ fakeSentryClient,
2087
+ ["mcp__extra__tool"],
2088
+ );
2089
+
2090
+ await runClaude("test");
2091
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2092
+
2093
+ const allowedIdx = cmd.indexOf("--allowedTools");
2094
+ const extraIdx = cmd.indexOf("mcp__extra__tool");
2095
+ const skillIdx = cmd.indexOf("Skill"); // built-in floor tool, last in FLOOR_TOOLS order
2096
+
2097
+ expect(extraIdx).toBeGreaterThan(allowedIdx);
2098
+ expect(extraIdx).toBeGreaterThan(skillIdx);
2099
+ });
2100
+
2101
+ test("dedup: allowedTools includes a built-in tool (Bash, from DB), final args contains it exactly once", async () => {
2102
+ const runClaude = createRunClaude(
2103
+ mockSpawn as typeof Bun.spawn,
2104
+ testSessions,
2105
+ MODEL,
2106
+ WORKSPACE,
2107
+ fakeSentryClient,
2108
+ ["Bash", "mcp__extra__tool"], // Bash is no longer hardcoded; now DB/extraAllowedTools-only
2109
+ );
2110
+
2111
+ await runClaude("test");
2112
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2113
+
2114
+ const toolsInCmd = extractAllowedTools(cmd);
2115
+
2116
+ // Count occurrences of Bash
2117
+ const bashCount = toolsInCmd.filter((tool) => tool === "Bash").length;
2118
+ expect(bashCount).toBe(1);
2119
+ expect(toolsInCmd).toContain("mcp__extra__tool");
2120
+ });
2121
+
2122
+ test("dedup: empty allowedTools still contains exactly the 7 FLOOR_TOOLS with no duplicates", async () => {
2123
+ const runClaudeEmpty = createRunClaude(
2124
+ mockSpawn as typeof Bun.spawn,
2125
+ testSessions,
2126
+ MODEL,
2127
+ WORKSPACE,
2128
+ fakeSentryClient,
2129
+ [], // empty extraAllowedTools
2130
+ );
2131
+
2132
+ await runClaudeEmpty("test");
2133
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2134
+
2135
+ const toolsInCmd = extractAllowedTools(cmd);
2136
+
2137
+ // Verify exactly the 7 FLOOR_TOOLS
2138
+ const floorTools = [
2139
+ "Read",
2140
+ "Write",
2141
+ "Edit",
2142
+ "Glob",
2143
+ "Grep",
2144
+ "TodoWrite",
2145
+ "Skill",
2146
+ ];
2147
+ expect(toolsInCmd).toEqual(floorTools);
2148
+
2149
+ // Verify no duplicates: unique count equals total count
2150
+ const uniqueTools = new Set(toolsInCmd);
2151
+ expect(uniqueTools.size).toBe(toolsInCmd.length);
2152
+ });
2153
+
2154
+ test("dedup: multiple overlapping tools, only unique entries in final args", async () => {
2155
+ const runClaude = createRunClaude(
2156
+ mockSpawn as typeof Bun.spawn,
2157
+ testSessions,
2158
+ MODEL,
2159
+ WORKSPACE,
2160
+ fakeSentryClient,
2161
+ ["Bash", "Write", "mcp__tool1", "Read", "mcp__tool2"], // Write and Read are floor; Bash comes from DB
2162
+ );
2163
+
2164
+ await runClaude("test");
2165
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2166
+
2167
+ const toolsInCmd = extractAllowedTools(cmd);
2168
+
2169
+ // Verify no duplicates
2170
+ const uniqueTools = new Set(toolsInCmd);
2171
+ expect(uniqueTools.size).toBe(toolsInCmd.length);
2172
+
2173
+ // Verify all expected tools are present
2174
+ expect(toolsInCmd).toContain("Bash");
2175
+ expect(toolsInCmd).toContain("Write");
2176
+ expect(toolsInCmd).toContain("Read");
2177
+ expect(toolsInCmd).toContain("mcp__tool1");
2178
+ expect(toolsInCmd).toContain("mcp__tool2");
2179
+ });
2180
+
2181
+ test("empty liveClaudeConfig.allowedTools: Bash/WebSearch/WebFetch/Agent are NOT in final args, only the 7 floor tools are present", async () => {
2182
+ const runClaudeEmpty = createRunClaude(
2183
+ mockSpawn as typeof Bun.spawn,
2184
+ testSessions,
2185
+ MODEL,
2186
+ WORKSPACE,
2187
+ fakeSentryClient,
2188
+ [], // simulates liveClaudeConfig.allowedTools === [] (no AgentTool rows / config bundle 404)
2189
+ );
2190
+
2191
+ await runClaudeEmpty("test");
2192
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2193
+
2194
+ const toolsInCmd = extractAllowedTools(cmd);
2195
+
2196
+ expect(toolsInCmd).not.toContain("Bash");
2197
+ expect(toolsInCmd).not.toContain("WebSearch");
2198
+ expect(toolsInCmd).not.toContain("WebFetch");
2199
+ expect(toolsInCmd).not.toContain("Agent");
2200
+
2201
+ expect(toolsInCmd).toEqual([
2202
+ "Read",
2203
+ "Write",
2204
+ "Edit",
2205
+ "Glob",
2206
+ "Grep",
2207
+ "TodoWrite",
2208
+ "Skill",
2209
+ ]);
2210
+ });
2211
+
2212
+ test("liveClaudeConfig.allowedTools includes Bash and Agent (from DB): final args include them exactly once each alongside the 7 floor tools", async () => {
2213
+ const runClaude = createRunClaude(
2214
+ mockSpawn as typeof Bun.spawn,
2215
+ testSessions,
2216
+ MODEL,
2217
+ WORKSPACE,
2218
+ fakeSentryClient,
2219
+ ["Bash", "Agent"], // simulates liveClaudeConfig.allowedTools seeded from AgentTool DB rows
2220
+ );
2221
+
2222
+ await runClaude("test");
2223
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2224
+
2225
+ const toolsInCmd = extractAllowedTools(cmd);
2226
+
2227
+ const bashCount = toolsInCmd.filter((tool) => tool === "Bash").length;
2228
+ const agentCount = toolsInCmd.filter((tool) => tool === "Agent").length;
2229
+ expect(bashCount).toBe(1);
2230
+ expect(agentCount).toBe(1);
2231
+
2232
+ for (const floorTool of [
2233
+ "Read",
2234
+ "Write",
2235
+ "Edit",
2236
+ "Glob",
2237
+ "Grep",
2238
+ "TodoWrite",
2239
+ "Skill",
2240
+ ]) {
2241
+ expect(toolsInCmd).toContain(floorTool);
2242
+ }
2243
+
2244
+ const uniqueTools = new Set(toolsInCmd);
2245
+ expect(uniqueTools.size).toBe(toolsInCmd.length);
2246
+ });
2247
+ });
2248
+
2249
+ // ─── totalCostUsd and modelUsage from JSON output ────────────────────────────
2250
+
2251
+ describe("runClaude — totalCostUsd and modelUsage", () => {
2252
+ // biome-ignore lint/suspicious/noExplicitAny: mock return type
2253
+ let mockSpawn: any;
2254
+ let runClaude: ReturnType<typeof createRunClaude>;
2255
+
2256
+ beforeEach(() => {
2257
+ mockGetSession.mockClear();
2258
+ mockSetSession.mockClear();
2259
+ mockClearSession.mockClear();
2260
+ capturedMessages = [];
2261
+ capturedExceptions = [];
2262
+ mockSpawn = mock(
2263
+ () =>
2264
+ fakeProc(jsonOutput("Hello from Claude")) as ReturnType<
2265
+ typeof Bun.spawn
2266
+ >,
2267
+ );
2268
+ runClaude = createRunClaude(
2269
+ mockSpawn as typeof Bun.spawn,
2270
+ testSessions,
2271
+ MODEL,
2272
+ WORKSPACE,
2273
+ fakeSentryClient,
2274
+ );
2275
+ });
2276
+
2277
+ test("returns totalCostUsd from JSON output when total_cost_usd is present", async () => {
2278
+ const json = JSON.stringify({
2279
+ type: "result",
2280
+ subtype: "success",
2281
+ result: "Hello",
2282
+ session_id: "sess-cost",
2283
+ is_error: false,
2284
+ total_cost_usd: 0.0042,
2285
+ });
2286
+ mockSpawn.mockReturnValue(fakeProc(json) as ReturnType<typeof Bun.spawn>);
2287
+ const output = await runClaude("hello");
2288
+ expect(output.totalCostUsd).toBe(0.0042);
2289
+ });
2290
+
2291
+ test("returns modelUsage from JSON output when modelUsage is present", async () => {
2292
+ const modelUsage = {
2293
+ "claude-sonnet-4-6": {
2294
+ inputTokens: 10,
2295
+ outputTokens: 5,
2296
+ cacheReadInputTokens: 0,
2297
+ cacheCreationInputTokens: 0,
2298
+ webSearchRequests: 0,
2299
+ costUSD: 0.0021,
2300
+ contextWindow: 200000,
2301
+ maxOutputTokens: 8192,
2302
+ },
2303
+ };
2304
+ const json = JSON.stringify({
2305
+ type: "result",
2306
+ subtype: "success",
2307
+ result: "Hello",
2308
+ session_id: "sess-usage",
2309
+ is_error: false,
2310
+ modelUsage,
2311
+ });
2312
+ mockSpawn.mockReturnValue(fakeProc(json) as ReturnType<typeof Bun.spawn>);
2313
+ const output = await runClaude("hello");
2314
+ expect(output.modelUsage).toEqual(modelUsage);
2315
+ });
2316
+
2317
+ test("returns undefined totalCostUsd and modelUsage when not in JSON output", async () => {
2318
+ // Default mockSpawn produces jsonOutput("Hello from Claude") which has no extra fields
2319
+ const output = await runClaude("hello");
2320
+ expect(output.totalCostUsd).toBeUndefined();
2321
+ expect(output.modelUsage).toBeUndefined();
2322
+ });
2323
+ });
2324
+
2325
+ // ─── stream-json parsing / onProgress ────────────────────────────────────────
2326
+
2327
+ describe("runClaude — stream-json parsing", () => {
2328
+ beforeEach(() => {
2329
+ mockGetSession.mockReset();
2330
+ mockSetSession.mockReset();
2331
+ mockClearSession.mockReset();
2332
+ capturedMessages = [];
2333
+ capturedExceptions = [];
2334
+ });
2335
+
2336
+ test("clean multi-turn, multi-model session returns the result event's authoritative totals byte-identically", async () => {
2337
+ const mockSpawn = mock(
2338
+ () => ndjsonProc(cleanMultiTurn.lines) as ReturnType<typeof Bun.spawn>,
2339
+ );
2340
+ const runClaude = createRunClaude(
2341
+ mockSpawn as typeof Bun.spawn,
2342
+ testSessions,
2343
+ MODEL,
2344
+ WORKSPACE,
2345
+ fakeSentryClient,
2346
+ );
2347
+
2348
+ const output = await runClaude("go");
2349
+
2350
+ expect(output.result).toBe(cleanMultiTurn.expected.result);
2351
+ expect(output.sessionId).toBe(cleanMultiTurn.expected.sessionId);
2352
+ expect(output.totalCostUsd).toBe(cleanMultiTurn.expected.totalCostUsd);
2353
+ // Byte-identical to the terminal result event's modelUsage (NOT the
2354
+ // running accumulator, which lacks costUSD).
2355
+ expect(output.modelUsage).toEqual(cleanMultiTurn.expected.modelUsage);
2356
+ expect(output.streamIncomplete).toBeUndefined();
2357
+ });
2358
+
2359
+ test("onProgress fires once per distinct message id with the running accumulated total (deduped)", async () => {
2360
+ const mockSpawn = mock(
2361
+ () => ndjsonProc(cleanMultiTurn.lines) as ReturnType<typeof Bun.spawn>,
2362
+ );
2363
+ const progress: Array<Record<string, unknown>> = [];
2364
+ const runClaude = createRunClaude(
2365
+ mockSpawn as typeof Bun.spawn,
2366
+ testSessions,
2367
+ MODEL,
2368
+ WORKSPACE,
2369
+ fakeSentryClient,
2370
+ undefined,
2371
+ undefined,
2372
+ undefined,
2373
+ undefined,
2374
+ undefined,
2375
+ (mu, phase) => {
2376
+ // Isolate the usage-triggered fires (phase undefined) from the
2377
+ // separate phase-triggered fires (CFB-2.1) this same callback also
2378
+ // now receives — this test is specifically about usage accumulation.
2379
+ if (phase === undefined) progress.push(structuredClone(mu));
2380
+ },
2381
+ );
2382
+
2383
+ await runClaude("go");
2384
+
2385
+ // msg_1 (deduped from 2 lines), msg_2, msg_3 → exactly 3 progress events.
2386
+ expect(progress).toHaveLength(cleanMultiTurn.expectedProgressCount);
2387
+ // The final accumulated snapshot matches the fixture's own expected sums.
2388
+ expect(progress[progress.length - 1]).toEqual(
2389
+ cleanMultiTurn.expectedAccumulated,
2390
+ );
2391
+ });
2392
+
2393
+ test("per-call onProgress fires once per distinct message id with the running accumulated total (deduped)", async () => {
2394
+ const mockSpawn = mock(
2395
+ () => ndjsonProc(cleanMultiTurn.lines) as ReturnType<typeof Bun.spawn>,
2396
+ );
2397
+ const progress: Array<Record<string, unknown>> = [];
2398
+ // No construction-time onProgress bound — only the per-call one.
2399
+ const runClaude = createRunClaude(
2400
+ mockSpawn as typeof Bun.spawn,
2401
+ testSessions,
2402
+ MODEL,
2403
+ WORKSPACE,
2404
+ fakeSentryClient,
2405
+ );
2406
+
2407
+ await runClaude("go", undefined, (mu, phase) => {
2408
+ if (phase === undefined) progress.push(structuredClone(mu));
2409
+ });
2410
+
2411
+ expect(progress).toHaveLength(cleanMultiTurn.expectedProgressCount);
2412
+ expect(progress[progress.length - 1]).toEqual(
2413
+ cleanMultiTurn.expectedAccumulated,
2414
+ );
2415
+ });
2416
+
2417
+ test("both a construction-time onProgress and a per-call onProgress fire together on the same turn with an identical snapshot", async () => {
2418
+ const mockSpawn = mock(
2419
+ () => ndjsonProc(cleanMultiTurn.lines) as ReturnType<typeof Bun.spawn>,
2420
+ );
2421
+ const constructionProgress: Array<Record<string, unknown>> = [];
2422
+ const perCallProgress: Array<Record<string, unknown>> = [];
2423
+ const runClaude = createRunClaude(
2424
+ mockSpawn as typeof Bun.spawn,
2425
+ testSessions,
2426
+ MODEL,
2427
+ WORKSPACE,
2428
+ fakeSentryClient,
2429
+ undefined,
2430
+ undefined,
2431
+ undefined,
2432
+ undefined,
2433
+ undefined,
2434
+ (mu, phase) => {
2435
+ if (phase === undefined) constructionProgress.push(structuredClone(mu));
2436
+ },
2437
+ );
2438
+
2439
+ await runClaude("go", undefined, (mu, phase) => {
2440
+ if (phase === undefined) perCallProgress.push(structuredClone(mu));
2441
+ });
2442
+
2443
+ expect(constructionProgress).toHaveLength(
2444
+ cleanMultiTurn.expectedProgressCount,
2445
+ );
2446
+ expect(perCallProgress).toHaveLength(cleanMultiTurn.expectedProgressCount);
2447
+ // Identical snapshots on every turn.
2448
+ expect(perCallProgress).toEqual(constructionProgress);
2449
+ });
2450
+
2451
+ test("skips malformed / non-JSON lines without losing already-accumulated usage", async () => {
2452
+ const linesWithGarbage = [
2453
+ cleanMultiTurn.lines[0],
2454
+ "this is not json at all",
2455
+ ...cleanMultiTurn.lines.slice(1, 2),
2456
+ "{ broken json",
2457
+ ...cleanMultiTurn.lines.slice(2),
2458
+ ];
2459
+ const mockSpawn = mock(
2460
+ () => ndjsonProc(linesWithGarbage) as ReturnType<typeof Bun.spawn>,
2461
+ );
2462
+ const runClaude = createRunClaude(
2463
+ mockSpawn as typeof Bun.spawn,
2464
+ testSessions,
2465
+ MODEL,
2466
+ WORKSPACE,
2467
+ fakeSentryClient,
2468
+ );
2469
+
2470
+ const output = await runClaude("go");
2471
+ expect(output.result).toBe(cleanMultiTurn.expected.result);
2472
+ expect(output.modelUsage).toEqual(cleanMultiTurn.expected.modelUsage);
2473
+ });
2474
+
2475
+ test("truncated stream with no result event surfaces the accumulated partial usage (distinct return shape)", async () => {
2476
+ const mockSpawn = mock(
2477
+ () => ndjsonProc(truncatedNoResult.lines) as ReturnType<typeof Bun.spawn>,
2478
+ );
2479
+ const runClaude = createRunClaude(
2480
+ mockSpawn as typeof Bun.spawn,
2481
+ testSessions,
2482
+ MODEL,
2483
+ WORKSPACE,
2484
+ fakeSentryClient,
2485
+ );
2486
+
2487
+ const output = await runClaude("go");
2488
+ expect(output.streamIncomplete).toBe(true);
2489
+ expect(output.modelUsage).toEqual(truncatedNoResult.expectedAccumulated);
2490
+ expect(output.result).toBe("");
2491
+ });
2492
+
2493
+ test("clean exit with no result event still surfaces the session id captured off the leading system/init line", async () => {
2494
+ const mockSpawn = mock(
2495
+ () => ndjsonProc(cleanExitNoResult.lines) as ReturnType<typeof Bun.spawn>,
2496
+ );
2497
+ const runClaude = createRunClaude(
2498
+ mockSpawn as typeof Bun.spawn,
2499
+ testSessions,
2500
+ MODEL,
2501
+ WORKSPACE,
2502
+ fakeSentryClient,
2503
+ );
2504
+
2505
+ const output = await runClaude("go");
2506
+ expect(output.streamIncomplete).toBe(true);
2507
+ expect(output.sessionId).toBe(cleanExitNoResult.expectedSessionId);
2508
+ expect(output.modelUsage).toEqual(cleanExitNoResult.expectedAccumulated);
2509
+ });
2510
+
2511
+ test("truncated non-zero exit with no result event falls back to the init line's session id on ClaudeRunError", async () => {
2512
+ const mockSpawn = mock(
2513
+ () =>
2514
+ ndjsonProc(truncatedNoResult.lines, "", 1) as ReturnType<
2515
+ typeof Bun.spawn
2516
+ >,
2517
+ );
2518
+ const runClaude = createRunClaude(
2519
+ mockSpawn as typeof Bun.spawn,
2520
+ testSessions,
2521
+ MODEL,
2522
+ WORKSPACE,
2523
+ fakeSentryClient,
2524
+ );
2525
+
2526
+ const { ClaudeRunError } = await import("./claude.ts");
2527
+ try {
2528
+ await runClaude("go");
2529
+ throw new Error("expected throw");
2530
+ } catch (err) {
2531
+ expect(err).toBeInstanceOf(ClaudeRunError);
2532
+ const e = err as InstanceType<typeof ClaudeRunError>;
2533
+ // No `result` event ever arrived — session id must fall back to the
2534
+ // one captured off the leading system/init line, not result?.session_id.
2535
+ expect(e.sessionId).toBe(truncatedNoResult.expectedSessionId);
2536
+ expect(e.modelUsage).toEqual(truncatedNoResult.expectedAccumulated);
2537
+ }
2538
+ });
2539
+
2540
+ test("timeout mid-stream throws ClaudeTimeoutError carrying the accumulated partial usage", async () => {
2541
+ // A proc whose stdout emits two assistant turns then never closes / never
2542
+ // emits a result — forcing the timeout path.
2543
+ let resolveExited!: (code: number) => void;
2544
+ const exited = new Promise<number>((r) => {
2545
+ resolveExited = r;
2546
+ });
2547
+ const enc = new TextEncoder();
2548
+ // Mirror real proc.kill() semantics: killing the process closes stdout so
2549
+ // the reader drains to `done` instead of hanging forever.
2550
+ let streamController!: ReadableStreamDefaultController<Uint8Array>;
2551
+ const stdout = new ReadableStream<Uint8Array>({
2552
+ start(controller) {
2553
+ streamController = controller;
2554
+ controller.enqueue(enc.encode(`${truncatedNoResult.lines[0]}\n`));
2555
+ controller.enqueue(enc.encode(`${truncatedNoResult.lines[1]}\n`));
2556
+ controller.enqueue(enc.encode(`${truncatedNoResult.lines[2]}\n`));
2557
+ // stream intentionally left open — no close(), no result line
2558
+ },
2559
+ });
2560
+ const proc = {
2561
+ stdout,
2562
+ stderr: bodyStream(""),
2563
+ exited,
2564
+ kill: () => {
2565
+ streamController.close();
2566
+ resolveExited(143);
2567
+ },
2568
+ };
2569
+ const mockSpawn = mock(
2570
+ () => proc as unknown as ReturnType<typeof Bun.spawn>,
2571
+ );
2572
+
2573
+ const runClaudeWithTimeout = createRunClaude(
2574
+ mockSpawn as typeof Bun.spawn,
2575
+ testSessions,
2576
+ MODEL,
2577
+ WORKSPACE,
2578
+ fakeSentryClient,
2579
+ undefined,
2580
+ undefined,
2581
+ undefined,
2582
+ 1000, // 1s ceiling headroom — idle timer below should fire first
2583
+ 10, // 10ms idle timeout — stream goes silent after the 3 initial lines
2584
+ );
2585
+
2586
+ const { ClaudeTimeoutError } = await import("./claude.ts");
2587
+ try {
2588
+ await runClaudeWithTimeout("go");
2589
+ throw new Error("expected timeout");
2590
+ } catch (err) {
2591
+ expect(err).toBeInstanceOf(ClaudeTimeoutError);
2592
+ const e = err as InstanceType<typeof ClaudeTimeoutError>;
2593
+ expect(e.reason).toBe("idle");
2594
+ expect(e.partialModelUsage).toEqual(
2595
+ truncatedNoResult.expectedAccumulated,
2596
+ );
2597
+ // The init line (first of the 3 emitted lines) carries a session id —
2598
+ // it must survive onto the timeout error even though the process was
2599
+ // killed before any terminal `result` event arrived.
2600
+ expect(e.sessionId).toBe(truncatedNoResult.expectedSessionId);
2601
+ }
2602
+ });
2603
+ });
2604
+
2605
+ // ─── progress phases ──────────────────────────────────────────────────────────
2606
+
2607
+ describe("runClaude — progress phases derived from message.content", () => {
2608
+ test("fires the expected collapsed phase sequence, in order, via onProgress", async () => {
2609
+ const mockSpawn = mock(
2610
+ () => ndjsonProc(progressPhases.lines) as ReturnType<typeof Bun.spawn>,
2611
+ );
2612
+ const phases: Array<string | undefined> = [];
2613
+ const runClaude = createRunClaude(
2614
+ mockSpawn as typeof Bun.spawn,
2615
+ testSessions,
2616
+ MODEL,
2617
+ WORKSPACE,
2618
+ fakeSentryClient,
2619
+ undefined,
2620
+ undefined,
2621
+ undefined,
2622
+ undefined,
2623
+ undefined,
2624
+ (_mu, phase) => {
2625
+ if (phase !== undefined) phases.push(phase);
2626
+ },
2627
+ );
2628
+
2629
+ await runClaude("go");
2630
+
2631
+ expect(phases).toEqual(progressPhases.expectedPhaseSequence);
2632
+ });
2633
+
2634
+ test("fires the same collapsed phase sequence via the per-call onProgress", async () => {
2635
+ const mockSpawn = mock(
2636
+ () => ndjsonProc(progressPhases.lines) as ReturnType<typeof Bun.spawn>,
2637
+ );
2638
+ const phases: Array<string | undefined> = [];
2639
+ const runClaude = createRunClaude(
2640
+ mockSpawn as typeof Bun.spawn,
2641
+ testSessions,
2642
+ MODEL,
2643
+ WORKSPACE,
2644
+ fakeSentryClient,
2645
+ );
2646
+
2647
+ await runClaude("go", undefined, (_mu, phase) => {
2648
+ if (phase !== undefined) phases.push(phase);
2649
+ });
2650
+
2651
+ expect(phases).toEqual(progressPhases.expectedPhaseSequence);
2652
+ });
2653
+
2654
+ test("fires a phase for a tool_use-only line with no usage field (before the usage guard)", async () => {
2655
+ // msg_1 in the fixture carries a thinking block + a tool_use block and NO
2656
+ // top-level `usage` field — the existing `if (!message?.id || !message.usage)
2657
+ // return;` guard must not suppress the phase fire for this line.
2658
+ const mockSpawn = mock(
2659
+ () => ndjsonProc(progressPhases.lines) as ReturnType<typeof Bun.spawn>,
2660
+ );
2661
+ const phases: Array<string | undefined> = [];
2662
+ const runClaude = createRunClaude(
2663
+ mockSpawn as typeof Bun.spawn,
2664
+ testSessions,
2665
+ MODEL,
2666
+ WORKSPACE,
2667
+ fakeSentryClient,
2668
+ undefined,
2669
+ undefined,
2670
+ undefined,
2671
+ undefined,
2672
+ undefined,
2673
+ (_mu, phase) => {
2674
+ if (phase !== undefined) phases.push(phase);
2675
+ },
2676
+ );
2677
+
2678
+ await runClaude("go");
2679
+
2680
+ // "thinking" and "reading" both come from msg_1, the usage-less line.
2681
+ expect(phases[0]).toBe("thinking");
2682
+ expect(phases[1]).toBe("reading");
2683
+ });
2684
+
2685
+ test("never surfaces secret/path substrings embedded in tool_use.input anywhere in fired phases", async () => {
2686
+ const mockSpawn = mock(
2687
+ () => ndjsonProc(progressPhases.lines) as ReturnType<typeof Bun.spawn>,
2688
+ );
2689
+ const phases: Array<string | undefined> = [];
2690
+ const runClaude = createRunClaude(
2691
+ mockSpawn as typeof Bun.spawn,
2692
+ testSessions,
2693
+ MODEL,
2694
+ WORKSPACE,
2695
+ fakeSentryClient,
2696
+ undefined,
2697
+ undefined,
2698
+ undefined,
2699
+ undefined,
2700
+ undefined,
2701
+ (_mu, phase) => {
2702
+ phases.push(phase);
2703
+ },
2704
+ );
2705
+
2706
+ await runClaude("go");
2707
+
2708
+ const serialized = JSON.stringify(phases);
2709
+ for (const secret of progressPhases.forbiddenSubstrings) {
2710
+ expect(serialized).not.toContain(secret);
2711
+ }
2712
+ });
2713
+
2714
+ test("passes the accumulated usage snapshot alongside the phase even when only the phase changed", async () => {
2715
+ const mockSpawn = mock(
2716
+ () => ndjsonProc(progressPhases.lines) as ReturnType<typeof Bun.spawn>,
2717
+ );
2718
+ const calls: Array<[Record<string, unknown>, string | undefined]> = [];
2719
+ const runClaude = createRunClaude(
2720
+ mockSpawn as typeof Bun.spawn,
2721
+ testSessions,
2722
+ MODEL,
2723
+ WORKSPACE,
2724
+ fakeSentryClient,
2725
+ undefined,
2726
+ undefined,
2727
+ undefined,
2728
+ undefined,
2729
+ undefined,
2730
+ (mu, phase) => {
2731
+ calls.push([structuredClone(mu), phase]);
2732
+ },
2733
+ );
2734
+
2735
+ await runClaude("go");
2736
+
2737
+ // The very first call (msg_1's thinking block, no usage field yet) must
2738
+ // still receive a (possibly empty) modelUsage snapshot object alongside
2739
+ // the phase — never undefined/omitted.
2740
+ expect(calls[0][0]).toEqual({});
2741
+ expect(calls[0][1]).toBe("thinking");
2742
+ });
2743
+ });
2744
+
2745
+ // ─── dominantModel ────────────────────────────────────────────────────────────
2746
+
2747
+ describe("dominantModel", () => {
2748
+ test("returns the model with the highest outputTokens", () => {
2749
+ const result = dominantModel({
2750
+ "claude-sonnet-4-6": {
2751
+ inputTokens: 100,
2752
+ outputTokens: 50,
2753
+ cacheReadInputTokens: 0,
2754
+ cacheCreationInputTokens: 0,
2755
+ costUSD: 0.001,
2756
+ },
2757
+ "claude-opus-4-8": {
2758
+ inputTokens: 200,
2759
+ outputTokens: 200,
2760
+ cacheReadInputTokens: 0,
2761
+ cacheCreationInputTokens: 0,
2762
+ costUSD: 0.01,
2763
+ },
2764
+ });
2765
+ expect(result).toBe("claude-opus-4-8");
2766
+ });
2767
+
2768
+ test("returns undefined for empty modelUsage", () => {
2769
+ expect(dominantModel({})).toBeUndefined();
2770
+ });
2771
+
2772
+ test("handles single model", () => {
2773
+ const result = dominantModel({
2774
+ "claude-haiku-4-6": {
2775
+ inputTokens: 10,
2776
+ outputTokens: 5,
2777
+ cacheReadInputTokens: 0,
2778
+ cacheCreationInputTokens: 0,
2779
+ costUSD: 0.0001,
2780
+ },
2781
+ });
2782
+ expect(result).toBe("claude-haiku-4-6");
2783
+ });
2784
+
2785
+ test("breaks ties deterministically — returns one of the tied models", () => {
2786
+ const result = dominantModel({
2787
+ "model-a": {
2788
+ inputTokens: 10,
2789
+ outputTokens: 100,
2790
+ cacheReadInputTokens: 0,
2791
+ cacheCreationInputTokens: 0,
2792
+ costUSD: 0.001,
2793
+ },
2794
+ "model-b": {
2795
+ inputTokens: 10,
2796
+ outputTokens: 100,
2797
+ cacheReadInputTokens: 0,
2798
+ cacheCreationInputTokens: 0,
2799
+ costUSD: 0.001,
2800
+ },
2801
+ });
2802
+ expect(result === "model-a" || result === "model-b").toBe(true);
2803
+ });
2804
+ });
2805
+
2806
+ // ─── liveClaudeConfig / setLiveClaudeConfig tests ────────────────────────────
2807
+
2808
+ describe("liveClaudeConfig", () => {
2809
+ // biome-ignore lint/suspicious/noExplicitAny: mock return type
2810
+ let mockSpawn: any;
2811
+
2812
+ beforeEach(() => {
2813
+ mockGetSession.mockClear();
2814
+ mockSetSession.mockClear();
2815
+ mockClearSession.mockClear();
2816
+ capturedMessages = [];
2817
+ capturedExceptions = [];
2818
+ mockSpawn = mock(
2819
+ () =>
2820
+ fakeProc(jsonOutput("Hello from Claude")) as ReturnType<
2821
+ typeof Bun.spawn
2822
+ >,
2823
+ );
2824
+ });
2825
+
2826
+ test("setLiveClaudeConfig changes --model for module-level runClaude", async () => {
2827
+ setLiveClaudeConfig({ model: "claude-haiku-4-6" });
2828
+
2829
+ // createRunClaude() with no explicit model should pick up liveClaudeConfig
2830
+ const runClaudeDefault = createRunClaude(
2831
+ mockSpawn as typeof Bun.spawn,
2832
+ testSessions,
2833
+ undefined,
2834
+ WORKSPACE,
2835
+ fakeSentryClient,
2836
+ );
2837
+
2838
+ await runClaudeDefault("test");
2839
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2840
+ const idx = cmd.indexOf("--model");
2841
+ expect(idx).toBeGreaterThan(-1);
2842
+ expect(cmd[idx + 1]).toBe("claude-haiku-4-6");
2843
+ });
2844
+
2845
+ test("explicit model passed to createRunClaude overrides liveClaudeConfig", async () => {
2846
+ setLiveClaudeConfig({ model: "claude-haiku-4-6" });
2847
+
2848
+ // Explicit model param takes precedence
2849
+ const runClaudeExplicit = createRunClaude(
2850
+ mockSpawn as typeof Bun.spawn,
2851
+ testSessions,
2852
+ "claude-opus-4-6",
2853
+ WORKSPACE,
2854
+ fakeSentryClient,
2855
+ );
2856
+
2857
+ await runClaudeExplicit("test");
2858
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2859
+ const idx = cmd.indexOf("--model");
2860
+ expect(idx).toBeGreaterThan(-1);
2861
+ expect(cmd[idx + 1]).toBe("claude-opus-4-6");
2862
+ });
2863
+
2864
+ test("setLiveClaudeConfig merges partial patches", async () => {
2865
+ setLiveClaudeConfig({ model: "claude-sonnet-4-6", effortLevel: "xhigh" });
2866
+
2867
+ const runClaudeDefault = createRunClaude(
2868
+ mockSpawn as typeof Bun.spawn,
2869
+ testSessions,
2870
+ undefined,
2871
+ WORKSPACE,
2872
+ fakeSentryClient,
2873
+ );
2874
+
2875
+ await runClaudeDefault("test");
2876
+ const [cmd] = mockSpawn.mock.calls[0] as [string[]];
2877
+
2878
+ const modelIdx = cmd.indexOf("--model");
2879
+ expect(cmd[modelIdx + 1]).toBe("claude-sonnet-4-6");
2880
+
2881
+ const effortIdx = cmd.indexOf("--effort");
2882
+ expect(effortIdx).toBeGreaterThan(-1);
2883
+ expect(cmd[effortIdx + 1]).toBe("xhigh");
2884
+ });
2885
+ });
2886
+
2887
+ // ─── reportClaudeError ──────────────────────────────────────────────────────
2888
+ //
2889
+ // SENTRY-46: a ClaudeTimeoutError with reason:"ceiling" is the intentional
2890
+ // 1hr hard-ceiling backstop firing on a legitimately long-running session —
2891
+ // expected, not a defect — so it must be downgraded to captureMessage rather
2892
+ // than captureException (which creates an actionable Sentry Issue that keeps
2893
+ // re-triggering error-patrol as "regressed"). reason:"idle" (a genuine hang)
2894
+ // and any other error must remain unchanged (captureException).
2895
+
2896
+ describe("reportClaudeError", () => {
2897
+ beforeEach(() => {
2898
+ capturedMessages = [];
2899
+ capturedExceptions = [];
2900
+ });
2901
+
2902
+ test("a ceiling-reason ClaudeTimeoutError calls captureMessage, not captureException", () => {
2903
+ const err = new ClaudeTimeoutError(3_600_000, "ceiling");
2904
+
2905
+ reportClaudeError(fakeSentryClient, err);
2906
+
2907
+ expect(capturedExceptions).toHaveLength(0);
2908
+ expect(capturedMessages).toHaveLength(1);
2909
+ expect(capturedMessages[0]).toContain("3600s");
2910
+ });
2911
+
2912
+ test("an idle-reason ClaudeTimeoutError calls captureException, not captureMessage", () => {
2913
+ const err = new ClaudeTimeoutError(1_500_000, "idle");
2914
+
2915
+ reportClaudeError(fakeSentryClient, err);
2916
+
2917
+ expect(capturedMessages).toHaveLength(0);
2918
+ expect(capturedExceptions).toHaveLength(1);
2919
+ expect(capturedExceptions[0]).toBe(err);
2920
+ });
2921
+
2922
+ test("a non-ClaudeTimeoutError calls captureException, not captureMessage", () => {
2923
+ const err = new Error("some other failure");
2924
+
2925
+ reportClaudeError(fakeSentryClient, err);
2926
+
2927
+ expect(capturedMessages).toHaveLength(0);
2928
+ expect(capturedExceptions).toHaveLength(1);
2929
+ expect(capturedExceptions[0]).toBe(err);
2930
+ });
2931
+
2932
+ test("does not throw when sentryClient is undefined", () => {
2933
+ expect(() =>
2934
+ reportClaudeError(
2935
+ undefined,
2936
+ new ClaudeTimeoutError(3_600_000, "ceiling"),
2937
+ ),
2938
+ ).not.toThrow();
2939
+ expect(() => reportClaudeError(undefined, new Error("boom"))).not.toThrow();
2940
+ });
2941
+ });
2942
+
2943
+ // ─── run telemetry (first-turn baseline, counts, skill attribution) ──────────
2944
+
2945
+ describe("runClaude — run telemetry", () => {
2946
+ beforeEach(() => {
2947
+ mockGetSession.mockReset();
2948
+ mockSetSession.mockReset();
2949
+ mockClearSession.mockReset();
2950
+ });
2951
+
2952
+ test("a clean session returns the fixture's expected telemetry alongside the result", async () => {
2953
+ const mockSpawn = mock(
2954
+ () => ndjsonProc(skillAttribution.lines) as ReturnType<typeof Bun.spawn>,
2955
+ );
2956
+ const runClaude = createRunClaude(
2957
+ mockSpawn as typeof Bun.spawn,
2958
+ testSessions,
2959
+ MODEL,
2960
+ WORKSPACE,
2961
+ fakeSentryClient,
2962
+ );
2963
+
2964
+ const output = await runClaude("go");
2965
+
2966
+ expect(output.result).toBe("done");
2967
+ expect(output.telemetry).toEqual(skillAttribution.expectedTelemetry);
2968
+ });
2969
+
2970
+ test("a resumed session (-r) drops the first-turn baseline but keeps counts and attribution", async () => {
2971
+ mockGetSession.mockImplementation(() => "sess-prior");
2972
+ const mockSpawn = mock(
2973
+ () => ndjsonProc(skillAttribution.lines) as ReturnType<typeof Bun.spawn>,
2974
+ );
2975
+ const runClaude = createRunClaude(
2976
+ mockSpawn as typeof Bun.spawn,
2977
+ testSessions,
2978
+ MODEL,
2979
+ WORKSPACE,
2980
+ fakeSentryClient,
2981
+ );
2982
+
2983
+ const output = await runClaude("go", "thread-1");
2984
+
2985
+ const cmd = (mockSpawn.mock.calls[0] as unknown[])[0] as string[];
2986
+ expect(cmd).toContain("-r");
2987
+ expect(output.telemetry?.firstTurn).toBeUndefined();
2988
+ expect(output.telemetry?.turns).toBe(
2989
+ skillAttribution.expectedTelemetry.turns,
2990
+ );
2991
+ expect(output.telemetry?.skillUsage).toEqual(
2992
+ skillAttribution.expectedTelemetry.skillUsage,
2993
+ );
2994
+ });
2995
+
2996
+ test("a clean exit with no result event still carries telemetry on the streamIncomplete shape", async () => {
2997
+ const mockSpawn = mock(
2998
+ () => ndjsonProc(cleanExitNoResult.lines) as ReturnType<typeof Bun.spawn>,
2999
+ );
3000
+ const runClaude = createRunClaude(
3001
+ mockSpawn as typeof Bun.spawn,
3002
+ testSessions,
3003
+ MODEL,
3004
+ WORKSPACE,
3005
+ fakeSentryClient,
3006
+ );
3007
+
3008
+ const output = await runClaude("go");
3009
+
3010
+ expect(output.streamIncomplete).toBe(true);
3011
+ expect(output.telemetry?.turns).toBeGreaterThan(0);
3012
+ expect(output.telemetry?.firstTurn).toBeDefined();
3013
+ });
3014
+
3015
+ test("a non-zero exit after usage was seen attaches partialTelemetry to the ClaudeRunError", async () => {
3016
+ const mockSpawn = mock(
3017
+ () =>
3018
+ ndjsonProc(truncatedNoResult.lines, "boom", 1) as ReturnType<
3019
+ typeof Bun.spawn
3020
+ >,
3021
+ );
3022
+ const runClaude = createRunClaude(
3023
+ mockSpawn as typeof Bun.spawn,
3024
+ testSessions,
3025
+ MODEL,
3026
+ WORKSPACE,
3027
+ fakeSentryClient,
3028
+ );
3029
+
3030
+ let caught: unknown;
3031
+ try {
3032
+ await runClaude("go");
3033
+ } catch (err) {
3034
+ caught = err;
3035
+ }
3036
+ expect(caught).toBeInstanceOf(ClaudeRunError);
3037
+ const telemetry = (caught as InstanceType<typeof ClaudeRunError>)
3038
+ .partialTelemetry;
3039
+ expect(telemetry?.turns).toBeGreaterThan(0);
3040
+ expect(telemetry?.firstTurn).toBeDefined();
3041
+ });
3042
+ });