@app-vitals/agent-runtime 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
});
|