@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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,1177 @@
1
+ /**
2
+ * Unit tests for cron-handler.ts's preCheck runner.
3
+ *
4
+ * Strategy: inject a FAKE spawner (matching `typeof Bun.spawn`'s shape) via
5
+ * `CronHandlerDeps.spawner` instead of letting the real Bun.spawn(["bun",
6
+ * scriptPath]) execute a child process. This removes the CI-timing
7
+ * dependency that came from spawning ~15 real bun processes per test run
8
+ * (see PR #3006 / CPS-1.1) — no real subprocess behavior is exercised here.
9
+ * Mirrors claude.unit.test.ts's fakeProc/mockSpawn pattern.
10
+ *
11
+ * Path-resolution tests (script-not-found via plugin format, manifest
12
+ * resolution, relative/absolute path resolution) still touch the real
13
+ * filesystem (existsSync) since that logic runs before the spawn call, but
14
+ * the actual spawn once a scriptPath is found still goes through the fake
15
+ * spawner.
16
+ *
17
+ * No test in this suite spawns a real subprocess, including the
18
+ * `spawner ?? Bun.spawn` default-fallback itself — that default is a single
19
+ * destructuring assignment (cron-handler.ts:176), the same shape as
20
+ * claude.ts's `spawner: typeof Bun.spawn = Bun.spawn`, whose own test suite
21
+ * (claude.unit.test.ts) likewise never exercises the real-Bun.spawn path
22
+ * with an actual child process. An earlier revision of this suite kept one
23
+ * real-subprocess integration test to "verify the wiring end-to-end"; it was
24
+ * removed after real subprocess spawns were identified as the likely source
25
+ * of intermittent CI failures (random `Cannot call describe()/afterEach()
26
+ * after the test run has completed` errors in unrelated files under GH
27
+ * Actions load) — see CPS-1.1.
28
+ */
29
+
30
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
31
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import type { WebClient } from "@slack/web-api";
35
+ import {
36
+ ClaudeRunError,
37
+ type ClaudeRunResult,
38
+ ClaudeTimeoutError,
39
+ type EarlySessionIdCallback,
40
+ type ModelUsage,
41
+ type ProgressCallback,
42
+ type TokenUsage,
43
+ } from "./claude.ts";
44
+ import type { RunContextStamp } from "./context-stamp.ts";
45
+ import { buildTokenPayload, handleCronRequest } from "./cron-handler.ts";
46
+ import type { CronRunReporter, RunReportOpts } from "./cron-run-reporter.ts";
47
+ import type { RunTelemetry } from "./run-telemetry.ts";
48
+
49
+ // ─── Fake spawn helpers (mirrors claude.unit.test.ts) ──────────────────────
50
+
51
+ interface FakeProc {
52
+ stdout: ReadableStream<Uint8Array>;
53
+ stderr: ReadableStream<Uint8Array>;
54
+ exited: Promise<number>;
55
+ kill: () => void;
56
+ }
57
+
58
+ function bodyStream(content: string): ReadableStream<Uint8Array> {
59
+ const enc = new TextEncoder();
60
+ return new ReadableStream({
61
+ start(controller) {
62
+ controller.enqueue(enc.encode(content));
63
+ controller.close();
64
+ },
65
+ });
66
+ }
67
+
68
+ function fakeProc(stdout: string, stderr = "", exitCode = 0): FakeProc {
69
+ return {
70
+ stdout: bodyStream(stdout),
71
+ stderr: bodyStream(stderr),
72
+ exited: Promise.resolve(exitCode),
73
+ kill: () => {},
74
+ };
75
+ }
76
+
77
+ // ─── Shared mocks ─────────────────────────────────────────────────────────────
78
+
79
+ const mockPostMessage = mock(() =>
80
+ Promise.resolve({ ok: true, ts: "1234567890.000001" }),
81
+ );
82
+ const mockConversationsOpen = mock(() =>
83
+ Promise.resolve({ channel: { id: "D_DM_CHANNEL" } }),
84
+ );
85
+ const mockSlack = {
86
+ chat: { postMessage: mockPostMessage },
87
+ conversations: { open: mockConversationsOpen },
88
+ } as unknown as WebClient;
89
+
90
+ const mockRunner = mock(
91
+ (): Promise<{
92
+ result: string;
93
+ sessionId?: string;
94
+ usage?: TokenUsage;
95
+ totalCostUsd?: number;
96
+ modelUsage?: ModelUsage;
97
+ streamIncomplete?: boolean;
98
+ }> => Promise.resolve({ result: "claude reply", sessionId: "sess-1" }),
99
+ );
100
+
101
+ let mockSpawn: ReturnType<
102
+ typeof mock<(...args: unknown[]) => ReturnType<typeof Bun.spawn>>
103
+ >;
104
+
105
+ const deps = {
106
+ slack: mockSlack,
107
+ runner: mockRunner,
108
+ formatter: (text: string) => text, // identity — no markdown conversion in tests
109
+ };
110
+
111
+ afterEach(() => {
112
+ mockPostMessage.mockClear();
113
+ mockConversationsOpen.mockClear();
114
+ mockRunner.mockClear();
115
+ });
116
+
117
+ // ─── preCheck ─────────────────────────────────────────────────────────────────
118
+
119
+ describe("handleCronRequest — preCheck", () => {
120
+ let tmpDir: string;
121
+
122
+ beforeEach(() => {
123
+ tmpDir = mkdtempSync(join(tmpdir(), "cron-test-"));
124
+ mkdirSync(join(tmpDir, "shipwright", "scripts"), { recursive: true });
125
+ mockSpawn = mock(() => fakeProc("") as ReturnType<typeof Bun.spawn>);
126
+ });
127
+
128
+ test("skips job when preCheck script exits 1 (no work)", async () => {
129
+ const script = join(tmpDir, "check.ts");
130
+ writeFileSync(script, "process.exit(1);");
131
+ mockSpawn = mock(() => fakeProc("", "", 1) as ReturnType<typeof Bun.spawn>);
132
+
133
+ await handleCronRequest(
134
+ { jobId: "j1", prompt: "hello", channel: "C-TEST", preCheck: script },
135
+ { ...deps, spawner: mockSpawn as unknown as typeof Bun.spawn },
136
+ );
137
+
138
+ expect(mockRunner).not.toHaveBeenCalled();
139
+ expect(mockPostMessage).not.toHaveBeenCalled();
140
+ });
141
+
142
+ test("uses preCheck output as prompt when it exits 0 with output", async () => {
143
+ const script = join(tmpDir, "check.ts");
144
+ writeFileSync(script, `console.log("precheck prompt"); process.exit(0);`);
145
+ mockSpawn = mock(
146
+ () =>
147
+ fakeProc("precheck prompt\n", "", 0) as ReturnType<typeof Bun.spawn>,
148
+ );
149
+
150
+ await handleCronRequest(
151
+ { jobId: "j1", prompt: "original", channel: "C-TEST", preCheck: script },
152
+ { ...deps, spawner: mockSpawn as unknown as typeof Bun.spawn },
153
+ );
154
+
155
+ expect(mockRunner).toHaveBeenCalledTimes(1);
156
+ const runArg = (mockRunner.mock.calls as unknown as string[][])[0][0];
157
+ expect(runArg).toContain("precheck prompt");
158
+ });
159
+
160
+ test("preCheck inherits runtime process.env mutations (env: process.env passthrough)", async () => {
161
+ // config-sync mutates process.env at runtime (Object.assign every 60s). The
162
+ // preCheck child must see those mutations, not a Bun-startup snapshot —
163
+ // regression for a rotated SHIPWRIGHT_TASK_STORE_TOKEN causing 401s until
164
+ // the pod restarted. Since the fake spawner replaces the real subprocess,
165
+ // this is now verified by asserting the spawner was CALLED with
166
+ // `env: process.env` (the same live object reference), rather than by
167
+ // observing a real child process read a mutated env var.
168
+ const script = join(tmpDir, "check.ts");
169
+ writeFileSync(script, `console.log("ok"); process.exit(0);`);
170
+ mockSpawn = mock(
171
+ () => fakeProc("ok\n", "", 0) as ReturnType<typeof Bun.spawn>,
172
+ );
173
+
174
+ await handleCronRequest(
175
+ {
176
+ jobId: "j1",
177
+ prompt: "original",
178
+ channel: "C-TEST",
179
+ preCheck: script,
180
+ },
181
+ { ...deps, spawner: mockSpawn as unknown as typeof Bun.spawn },
182
+ );
183
+
184
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
185
+ const [, opts] = mockSpawn.mock.calls[0] as [
186
+ string[],
187
+ { env: NodeJS.ProcessEnv },
188
+ ];
189
+ // Must be the SAME live process.env object (not a boot-time snapshot) so
190
+ // runtime mutations (e.g. config-sync's Object.assign) are visible.
191
+ expect(opts.env).toBe(process.env);
192
+ });
193
+
194
+ test("runs preCheck with cwd = workspace (resolves state relative to workspace)", async () => {
195
+ // Plugin preChecks resolve state relative to process.cwd() — e.g.
196
+ // check-review.ts / check-deploy.ts (via check-helpers.ts) read
197
+ // workspace-relative files like `state/agent-policy.md`. The preCheck
198
+ // must run with cwd rooted at the workspace, not the agent's cwd (/app in
199
+ // prod). Verified here via the fake spawner's recorded call opts, since
200
+ // the fake replaces the real subprocess entirely.
201
+ const ws = join(tmpDir, "ws");
202
+ mkdirSync(join(ws, "state"), { recursive: true });
203
+ writeFileSync(join(ws, "state", "agent-policy.md"), "# Agent Policy\n");
204
+ const script = join(tmpDir, "check.ts");
205
+ writeFileSync(
206
+ script,
207
+ `import { existsSync } from "node:fs";\nconsole.log(existsSync("state/agent-policy.md") ? "HAS_STATE" : "NO_STATE");\nprocess.exit(0);`,
208
+ );
209
+ mockSpawn = mock(
210
+ () => fakeProc("HAS_STATE\n", "", 0) as ReturnType<typeof Bun.spawn>,
211
+ );
212
+
213
+ await handleCronRequest(
214
+ { jobId: "j1", prompt: "original", channel: "C-TEST", preCheck: script },
215
+ {
216
+ ...deps,
217
+ workspace: ws,
218
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
219
+ },
220
+ );
221
+
222
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
223
+ const [, opts] = mockSpawn.mock.calls[0] as [string[], { cwd: string }];
224
+ expect(opts.cwd).toBe(ws);
225
+
226
+ expect(mockRunner).toHaveBeenCalledTimes(1);
227
+ const runArg = (mockRunner.mock.calls as unknown as string[][])[0][0];
228
+ expect(runArg).toContain("HAS_STATE");
229
+ });
230
+
231
+ test("skips job when preCheck script not found (plugin: format)", async () => {
232
+ await handleCronRequest(
233
+ {
234
+ jobId: "j1",
235
+ prompt: "hello",
236
+ channel: "C-TEST",
237
+ preCheck: "nonexistent-plugin:check.ts",
238
+ },
239
+ {
240
+ ...deps,
241
+ pluginCacheDir: tmpDir,
242
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
243
+ },
244
+ );
245
+
246
+ expect(mockRunner).not.toHaveBeenCalled();
247
+ expect(mockSpawn).not.toHaveBeenCalled();
248
+ });
249
+
250
+ test("preCheck exits 2 → session skipped, runner NOT called, alert posted to alertsChannel", async () => {
251
+ const scriptPath = join(
252
+ tmpDir,
253
+ "shipwright",
254
+ "scripts",
255
+ "check-dev-task.ts",
256
+ );
257
+ writeFileSync(
258
+ scriptPath,
259
+ `#!/usr/bin/env bun\nprocess.stderr.write("something blew up\\n");\nprocess.exit(2);\n`,
260
+ { mode: 0o755 },
261
+ );
262
+ mockSpawn = mock(
263
+ () =>
264
+ fakeProc("", "something blew up\n", 2) as ReturnType<typeof Bun.spawn>,
265
+ );
266
+
267
+ await handleCronRequest(
268
+ {
269
+ jobId: "precheck-crash",
270
+ prompt: "original prompt",
271
+ silent: true,
272
+ preCheck: "shipwright:check-dev-task.ts",
273
+ },
274
+ {
275
+ ...deps,
276
+ pluginCacheDir: tmpDir,
277
+ alertsChannel: "C_ALERTS",
278
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
279
+ },
280
+ );
281
+
282
+ expect(mockRunner).not.toHaveBeenCalled();
283
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
284
+ const call = (
285
+ mockPostMessage.mock.calls as unknown as unknown[][]
286
+ )[0][0] as {
287
+ channel: string;
288
+ text: string;
289
+ };
290
+ expect(call.channel).toBe("C_ALERTS");
291
+ expect(call.text).toContain("precheck-crash");
292
+ expect(call.text).toContain("exit 2");
293
+ });
294
+
295
+ test("preCheck exits 2 with no alertsChannel → session skipped, no alert, no throw", async () => {
296
+ const scriptPath = join(
297
+ tmpDir,
298
+ "shipwright",
299
+ "scripts",
300
+ "check-dev-task.ts",
301
+ );
302
+ writeFileSync(scriptPath, "#!/usr/bin/env bun\nprocess.exit(2);\n", {
303
+ mode: 0o755,
304
+ });
305
+ mockSpawn = mock(() => fakeProc("", "", 2) as ReturnType<typeof Bun.spawn>);
306
+
307
+ await expect(
308
+ handleCronRequest(
309
+ {
310
+ jobId: "precheck-crash-no-alert",
311
+ prompt: "original prompt",
312
+ silent: true,
313
+ preCheck: "shipwright:check-dev-task.ts",
314
+ },
315
+ {
316
+ ...deps,
317
+ pluginCacheDir: tmpDir,
318
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
319
+ },
320
+ ),
321
+ ).resolves.toBeUndefined();
322
+
323
+ expect(mockRunner).not.toHaveBeenCalled();
324
+ expect(mockPostMessage).not.toHaveBeenCalled();
325
+ });
326
+
327
+ test("preCheck exits 0 with no output → session skipped, runner NOT called", async () => {
328
+ const scriptPath = join(
329
+ tmpDir,
330
+ "shipwright",
331
+ "scripts",
332
+ "check-dev-task.ts",
333
+ );
334
+ writeFileSync(scriptPath, "#!/usr/bin/env bun\nprocess.exit(0);\n", {
335
+ mode: 0o755,
336
+ });
337
+ mockSpawn = mock(() => fakeProc("", "", 0) as ReturnType<typeof Bun.spawn>);
338
+
339
+ await handleCronRequest(
340
+ {
341
+ jobId: "precheck-no-output",
342
+ prompt: "original prompt",
343
+ silent: true,
344
+ preCheck: "shipwright:check-dev-task.ts",
345
+ },
346
+ {
347
+ ...deps,
348
+ pluginCacheDir: tmpDir,
349
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
350
+ },
351
+ );
352
+
353
+ expect(mockRunner).not.toHaveBeenCalled();
354
+ });
355
+
356
+ test("preCheck script not found → warning logged, runner NOT called, no throw", async () => {
357
+ const warnMessages: string[] = [];
358
+ const originalWarn = console.warn;
359
+ console.warn = (...args: unknown[]) => {
360
+ warnMessages.push(args.map(String).join(" "));
361
+ };
362
+
363
+ try {
364
+ await expect(
365
+ handleCronRequest(
366
+ {
367
+ jobId: "precheck-missing",
368
+ prompt: "original prompt",
369
+ silent: true,
370
+ preCheck: "shipwright:check-dev-task.ts",
371
+ },
372
+ {
373
+ ...deps,
374
+ pluginCacheDir: tmpDir,
375
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
376
+ },
377
+ ),
378
+ ).resolves.toBeUndefined();
379
+
380
+ expect(mockRunner).not.toHaveBeenCalled();
381
+ expect(mockSpawn).not.toHaveBeenCalled();
382
+ expect(
383
+ warnMessages.some((m) => m.includes("preCheck script not found")),
384
+ ).toBe(true);
385
+ } finally {
386
+ console.warn = originalWarn;
387
+ }
388
+ });
389
+
390
+ test("preCheck resolves via installed_plugins.json manifest (production path)", async () => {
391
+ const installDir = mkdtempSync(join(tmpdir(), "cron-precheck-install-"));
392
+ const installPath = join(installDir, "shipwright");
393
+ mkdirSync(join(installPath, "scripts"), { recursive: true });
394
+
395
+ const scriptPath = join(installPath, "scripts", "check-dev-task.ts");
396
+ writeFileSync(
397
+ scriptPath,
398
+ `#!/usr/bin/env bun\nconsole.log("manifest path works");\nprocess.exit(0);\n`,
399
+ { mode: 0o755 },
400
+ );
401
+
402
+ const manifestDir = mkdtempSync(join(tmpdir(), "cron-precheck-manifest-"));
403
+ const manifestPath = join(manifestDir, "installed_plugins.json");
404
+ writeFileSync(
405
+ manifestPath,
406
+ JSON.stringify({
407
+ version: 2,
408
+ plugins: {
409
+ "shipwright@app-vitals/shipwright": [{ installPath }],
410
+ },
411
+ }),
412
+ );
413
+
414
+ mockSpawn = mock(
415
+ () =>
416
+ fakeProc("manifest path works\n", "", 0) as ReturnType<
417
+ typeof Bun.spawn
418
+ >,
419
+ );
420
+
421
+ // biome-ignore lint/suspicious/noExplicitAny: test mock — sessionId optional
422
+ mockRunner.mockResolvedValueOnce({ result: "done [silent]" } as any);
423
+
424
+ await handleCronRequest(
425
+ {
426
+ jobId: "precheck-manifest",
427
+ prompt: "original prompt",
428
+ silent: true,
429
+ preCheck: "shipwright:check-dev-task.ts",
430
+ },
431
+ {
432
+ ...deps,
433
+ pluginManifestPath: manifestPath,
434
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
435
+ },
436
+ );
437
+
438
+ expect(mockRunner).toHaveBeenCalledTimes(1);
439
+ const runArg = (mockRunner.mock.calls as unknown as string[][])[0][0];
440
+ expect(runArg).toContain("manifest path works");
441
+ });
442
+
443
+ test("preCheck manifest path: missing plugins.json → warning logged, runner NOT called", async () => {
444
+ const warnMessages: string[] = [];
445
+ const originalWarn = console.warn;
446
+ console.warn = (...args: unknown[]) => {
447
+ warnMessages.push(args.map(String).join(" "));
448
+ };
449
+
450
+ try {
451
+ await expect(
452
+ handleCronRequest(
453
+ {
454
+ jobId: "precheck-nomanifest",
455
+ prompt: "original prompt",
456
+ silent: true,
457
+ preCheck: "shipwright:check-dev-task.ts",
458
+ },
459
+ {
460
+ ...deps,
461
+ pluginManifestPath: join(
462
+ tmpDir,
463
+ "nonexistent_installed_plugins.json",
464
+ ),
465
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
466
+ },
467
+ ),
468
+ ).resolves.toBeUndefined();
469
+
470
+ expect(mockRunner).not.toHaveBeenCalled();
471
+ expect(mockSpawn).not.toHaveBeenCalled();
472
+ expect(
473
+ warnMessages.some(
474
+ (m) =>
475
+ m.includes("failed to read installed_plugins.json") ||
476
+ m.includes("preCheck script not found"),
477
+ ),
478
+ ).toBe(true);
479
+ } finally {
480
+ console.warn = originalWarn;
481
+ }
482
+ });
483
+
484
+ test("relative path (./scripts/check.ts) resolves against workspace, output becomes prompt", async () => {
485
+ const tmpWorkspace = mkdtempSync(join(tmpdir(), "cron-precheck-relpath-"));
486
+ mkdirSync(join(tmpWorkspace, "scripts"), { recursive: true });
487
+ const scriptPath = join(tmpWorkspace, "scripts", "check.ts");
488
+ writeFileSync(
489
+ scriptPath,
490
+ `#!/usr/bin/env bun\nconsole.log("workspace task found");\nprocess.exit(0);\n`,
491
+ { mode: 0o755 },
492
+ );
493
+
494
+ mockSpawn = mock(
495
+ () =>
496
+ fakeProc("workspace task found\n", "", 0) as ReturnType<
497
+ typeof Bun.spawn
498
+ >,
499
+ );
500
+
501
+ // biome-ignore lint/suspicious/noExplicitAny: test mock — sessionId optional
502
+ mockRunner.mockResolvedValueOnce({ result: "done [silent]" } as any);
503
+
504
+ await handleCronRequest(
505
+ {
506
+ jobId: "precheck-relpath",
507
+ prompt: "original prompt",
508
+ silent: true,
509
+ preCheck: "./scripts/check.ts",
510
+ },
511
+ {
512
+ ...deps,
513
+ workspace: tmpWorkspace,
514
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
515
+ },
516
+ );
517
+
518
+ expect(mockRunner).toHaveBeenCalledTimes(1);
519
+ const runArg = (mockRunner.mock.calls as unknown as string[][])[0][0];
520
+ expect(runArg).toContain("workspace task found");
521
+ });
522
+
523
+ test("absolute path resolves directly regardless of workspace", async () => {
524
+ const absDir = mkdtempSync(join(tmpdir(), "cron-precheck-abs-"));
525
+ const scriptPath = join(absDir, "absolute-check.ts");
526
+ writeFileSync(
527
+ scriptPath,
528
+ `#!/usr/bin/env bun\nconsole.log("absolute path works");\nprocess.exit(0);\n`,
529
+ { mode: 0o755 },
530
+ );
531
+
532
+ mockSpawn = mock(
533
+ () =>
534
+ fakeProc("absolute path works\n", "", 0) as ReturnType<
535
+ typeof Bun.spawn
536
+ >,
537
+ );
538
+
539
+ // biome-ignore lint/suspicious/noExplicitAny: test mock — sessionId optional
540
+ mockRunner.mockResolvedValueOnce({ result: "done [silent]" } as any);
541
+
542
+ await handleCronRequest(
543
+ {
544
+ jobId: "precheck-absolute",
545
+ prompt: "original prompt",
546
+ silent: true,
547
+ preCheck: scriptPath,
548
+ },
549
+ { ...deps, spawner: mockSpawn as unknown as typeof Bun.spawn },
550
+ );
551
+
552
+ expect(mockRunner).toHaveBeenCalledTimes(1);
553
+ const runArg = (mockRunner.mock.calls as unknown as string[][])[0][0];
554
+ expect(runArg).toContain("absolute path works");
555
+ });
556
+
557
+ test("relative path without workspace → warning, runner NOT called", async () => {
558
+ const warnMessages: string[] = [];
559
+ const originalWarn = console.warn;
560
+ console.warn = (...args: unknown[]) => {
561
+ warnMessages.push(args.map(String).join(" "));
562
+ };
563
+
564
+ try {
565
+ await expect(
566
+ handleCronRequest(
567
+ {
568
+ jobId: "precheck-relpath-noworkspace",
569
+ prompt: "original prompt",
570
+ silent: true,
571
+ preCheck: "./scripts/check.ts",
572
+ },
573
+ { ...deps, spawner: mockSpawn as unknown as typeof Bun.spawn },
574
+ ),
575
+ ).resolves.toBeUndefined();
576
+
577
+ expect(mockRunner).not.toHaveBeenCalled();
578
+ expect(mockSpawn).not.toHaveBeenCalled();
579
+ expect(
580
+ warnMessages.some(
581
+ (m) => m.includes("relative") || m.includes("workspace"),
582
+ ),
583
+ ).toBe(true);
584
+ } finally {
585
+ console.warn = originalWarn;
586
+ }
587
+ });
588
+
589
+ test("relative path script not found → warning, runner NOT called", async () => {
590
+ const tmpWorkspace = mkdtempSync(join(tmpdir(), "cron-precheck-notfound-"));
591
+ const warnMessages: string[] = [];
592
+ const originalWarn = console.warn;
593
+ console.warn = (...args: unknown[]) => {
594
+ warnMessages.push(args.map(String).join(" "));
595
+ };
596
+
597
+ try {
598
+ await expect(
599
+ handleCronRequest(
600
+ {
601
+ jobId: "precheck-relpath-notfound",
602
+ prompt: "original prompt",
603
+ silent: true,
604
+ preCheck: "./nonexistent.ts",
605
+ },
606
+ {
607
+ ...deps,
608
+ workspace: tmpWorkspace,
609
+ spawner: mockSpawn as unknown as typeof Bun.spawn,
610
+ },
611
+ ),
612
+ ).resolves.toBeUndefined();
613
+
614
+ expect(mockRunner).not.toHaveBeenCalled();
615
+ expect(mockSpawn).not.toHaveBeenCalled();
616
+ expect(
617
+ warnMessages.some((m) => m.includes("preCheck script not found")),
618
+ ).toBe(true);
619
+ } finally {
620
+ console.warn = originalWarn;
621
+ }
622
+ });
623
+ });
624
+
625
+ // ─── SLACK_CHANNEL_ID extraEnv (STC-1.3) ───────────────────────────────────────
626
+
627
+ describe("handleCronRequest — empty text after marker stripping", () => {
628
+ test("channel job with marker-only output → no postMessage (avoids Slack no_text)", async () => {
629
+ mockRunner.mockResolvedValueOnce({ result: "[react:eyes]" } as any);
630
+ await handleCronRequest(
631
+ { jobId: "empty-ch", prompt: "hello", channel: "C-TEST" },
632
+ deps,
633
+ );
634
+ expect(mockPostMessage).not.toHaveBeenCalled();
635
+ });
636
+
637
+ test("DM job with blank output → no postMessage (avoids Slack no_text)", async () => {
638
+ mockRunner.mockResolvedValueOnce({ result: " \n" } as any);
639
+ await handleCronRequest(
640
+ { jobId: "empty-dm", prompt: "hello", user: "U-DAN" },
641
+ deps,
642
+ );
643
+ expect(mockPostMessage).not.toHaveBeenCalled();
644
+ });
645
+ });
646
+
647
+ describe("handleCronRequest — SLACK_CHANNEL_ID extraEnv (STC-1.3)", () => {
648
+ test("channel job → runner receives { SLACK_CHANNEL_ID: <channel> } extraEnv, no SLACK_THREAD_TS", async () => {
649
+ await handleCronRequest(
650
+ { jobId: "j1", prompt: "hello", channel: "C-TEST" },
651
+ deps,
652
+ );
653
+
654
+ expect(mockRunner).toHaveBeenCalledTimes(1);
655
+ const call = mockRunner.mock.calls[0] as unknown as [
656
+ string,
657
+ unknown,
658
+ Record<string, string> | undefined,
659
+ ];
660
+ expect(call[2]).toEqual({ SLACK_CHANNEL_ID: "C-TEST" });
661
+ });
662
+
663
+ test("user (DM) job, conversations.open succeeds → runner receives the DM channel id as SLACK_CHANNEL_ID, and post-run delivery reuses it (no second conversations.open call)", async () => {
664
+ await handleCronRequest(
665
+ { jobId: "dm-j1", prompt: "hello", user: "U-DAN" },
666
+ deps,
667
+ );
668
+
669
+ expect(mockRunner).toHaveBeenCalledTimes(1);
670
+ const call = mockRunner.mock.calls[0] as unknown as [
671
+ string,
672
+ unknown,
673
+ Record<string, string> | undefined,
674
+ ];
675
+ expect(call[2]).toEqual({ SLACK_CHANNEL_ID: "D_DM_CHANNEL" });
676
+
677
+ // Opened once (pre-run) and reused for post-run delivery — never opened twice.
678
+ expect(mockConversationsOpen).toHaveBeenCalledTimes(1);
679
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
680
+ expect(
681
+ (mockPostMessage.mock.calls as unknown as unknown[][])[0][0],
682
+ ).toMatchObject({ channel: "D_DM_CHANNEL" });
683
+ });
684
+
685
+ test("user (DM) job, conversations.open rejects → runner still called with no SLACK_CHANNEL_ID, [agent:cron] warning logged, post-run falls back to a second conversations.open call", async () => {
686
+ mockConversationsOpen.mockRejectedValueOnce(
687
+ new Error("slack network error"),
688
+ );
689
+
690
+ const warnMessages: string[] = [];
691
+ const originalWarn = console.warn;
692
+ console.warn = (...args: unknown[]) => {
693
+ warnMessages.push(args.map(String).join(" "));
694
+ };
695
+
696
+ try {
697
+ await handleCronRequest(
698
+ { jobId: "dm-j2", prompt: "hello", user: "U-DAN" },
699
+ deps,
700
+ );
701
+ } finally {
702
+ console.warn = originalWarn;
703
+ }
704
+
705
+ expect(mockRunner).toHaveBeenCalledTimes(1);
706
+ const call = mockRunner.mock.calls[0] as unknown as [
707
+ string,
708
+ unknown,
709
+ Record<string, string> | undefined,
710
+ ];
711
+ expect(call[2]).toBeUndefined();
712
+
713
+ expect(warnMessages.some((m) => m.includes("[agent:cron]"))).toBe(true);
714
+
715
+ // Pre-resolution failed — post-run falls back to opening the DM again.
716
+ expect(mockConversationsOpen).toHaveBeenCalledTimes(2);
717
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
718
+ expect(
719
+ (mockPostMessage.mock.calls as unknown as unknown[][])[0][0],
720
+ ).toMatchObject({ channel: "D_DM_CHANNEL" });
721
+ });
722
+
723
+ test("silent job with neither channel nor user → runner called with no extraEnv", async () => {
724
+ await handleCronRequest(
725
+ { jobId: "silent-j1", prompt: "hello", silent: true },
726
+ deps,
727
+ );
728
+
729
+ expect(mockRunner).toHaveBeenCalledTimes(1);
730
+ const call = mockRunner.mock.calls[0] as unknown as [
731
+ string,
732
+ unknown,
733
+ Record<string, string> | undefined,
734
+ ];
735
+ expect(call[2]).toBeUndefined();
736
+ });
737
+ });
738
+
739
+ // ─── SHIPWRIGHT_GH_OWNER hint (GOR-1.3) ────────────────────────────────────
740
+
741
+ describe("handleCronRequest — SHIPWRIGHT_GH_OWNER hint (GOR-1.3)", () => {
742
+ test("repo-less cron carries the default org, merged with SLACK_CHANNEL_ID", async () => {
743
+ await handleCronRequest(
744
+ { jobId: "gor-1", prompt: "hello", channel: "C-TEST" },
745
+ { ...deps, getDefaultOwner: () => "default-org" },
746
+ );
747
+ const call = mockRunner.mock.calls[0] as unknown as [
748
+ string,
749
+ unknown,
750
+ Record<string, string> | undefined,
751
+ ];
752
+ expect(call[2]).toEqual({
753
+ SLACK_CHANNEL_ID: "C-TEST",
754
+ SHIPWRIGHT_GH_OWNER: "default-org",
755
+ });
756
+ });
757
+
758
+ test("no resolvable default org → one warn naming cron + job id, no hint", async () => {
759
+ const warnMessages: string[] = [];
760
+ const originalWarn = console.warn;
761
+ console.warn = (...args: unknown[]) => {
762
+ warnMessages.push(args.map(String).join(" "));
763
+ };
764
+ try {
765
+ await handleCronRequest(
766
+ { jobId: "gor-2", prompt: "hello", channel: "C-TEST" },
767
+ { ...deps, getDefaultOwner: () => null },
768
+ );
769
+ } finally {
770
+ console.warn = originalWarn;
771
+ }
772
+ const call = mockRunner.mock.calls[0] as unknown as [
773
+ string,
774
+ unknown,
775
+ Record<string, string> | undefined,
776
+ ];
777
+ expect(call[2]).toEqual({ SLACK_CHANNEL_ID: "C-TEST" });
778
+ const owned = warnMessages.filter((m) => m.includes("gor-2"));
779
+ expect(owned).toHaveLength(1);
780
+ expect(owned[0]).toContain("cron");
781
+ });
782
+ });
783
+
784
+ // ─── onEarlySessionId wiring (CES-1.1) ──────────────────────────────────────
785
+ //
786
+ // The generic cron path (this file) has no session-id visibility on its
787
+ // cron-run log until completeRun fires at the very end of a dispatch —
788
+ // unlike loop-orchestrator.ts's dev-task dispatches, which already push an
789
+ // early session id via the same recordSessionId() reporter method. These
790
+ // tests assert handleCronRequest constructs a matching fire-and-forget
791
+ // onEarlySessionId callback and forwards it into runner() as the 4th
792
+ // positional arg, mirroring the onProgress/recordProgress wiring above.
793
+
794
+ function makeSessionIdRecordingReporter(
795
+ recordSessionIdImpl?: (
796
+ cronId: string,
797
+ runId: string | null,
798
+ sessionId: string,
799
+ ) => Promise<void>,
800
+ ): {
801
+ reporter: CronRunReporter;
802
+ sessionIdCalls: Array<{
803
+ cronId: string;
804
+ runId: string | null;
805
+ sessionId: string;
806
+ }>;
807
+ } {
808
+ const sessionIdCalls: Array<{
809
+ cronId: string;
810
+ runId: string | null;
811
+ sessionId: string;
812
+ }> = [];
813
+ const reporter: CronRunReporter = {
814
+ async createRun() {
815
+ return "run-early-1";
816
+ },
817
+ async completeRun() {},
818
+ async skipRun() {},
819
+ async recordProgress() {},
820
+ async recordSessionId(cronId, runId, sessionId) {
821
+ sessionIdCalls.push({ cronId, runId, sessionId });
822
+ if (recordSessionIdImpl)
823
+ await recordSessionIdImpl(cronId, runId, sessionId);
824
+ },
825
+ };
826
+ return { reporter, sessionIdCalls };
827
+ }
828
+
829
+ describe("handleCronRequest — onEarlySessionId wiring (CES-1.1)", () => {
830
+ test("runner is called with a 4th-arg onEarlySessionId callback that forwards into cronRunReporter.recordSessionId(jobId, runId, sid)", async () => {
831
+ const { reporter, sessionIdCalls } = makeSessionIdRecordingReporter();
832
+
833
+ let capturedOnEarlySessionId: EarlySessionIdCallback | undefined;
834
+ const runner = mock(
835
+ async (
836
+ _message: string,
837
+ _onProgress?: ProgressCallback,
838
+ _extraEnv?: Record<string, string>,
839
+ onEarlySessionId?: EarlySessionIdCallback,
840
+ ): Promise<ClaudeRunResult> => {
841
+ capturedOnEarlySessionId = onEarlySessionId;
842
+ return { result: "claude reply", sessionId: "sess-1" };
843
+ },
844
+ );
845
+
846
+ await handleCronRequest(
847
+ { jobId: "j-early", prompt: "hello", silent: true },
848
+ { ...deps, runner, cronRunReporter: reporter },
849
+ );
850
+
851
+ expect(runner).toHaveBeenCalledTimes(1);
852
+ expect(typeof capturedOnEarlySessionId).toBe("function");
853
+
854
+ capturedOnEarlySessionId?.("early-sess-id");
855
+ // recordSessionId is fire-and-forget — give its promise chain a tick.
856
+ await Promise.resolve();
857
+ await Promise.resolve();
858
+
859
+ expect(sessionIdCalls).toEqual([
860
+ { cronId: "j-early", runId: "run-early-1", sessionId: "early-sess-id" },
861
+ ]);
862
+ });
863
+
864
+ test("a cronRunReporter.recordSessionId rejection is caught and warn-logged, never thrown — dispatch still completes normally", async () => {
865
+ const { reporter } = makeSessionIdRecordingReporter(async () => {
866
+ throw new Error("network error");
867
+ });
868
+
869
+ let capturedOnEarlySessionId: EarlySessionIdCallback | undefined;
870
+ const runner = mock(
871
+ async (
872
+ _message: string,
873
+ _onProgress?: ProgressCallback,
874
+ _extraEnv?: Record<string, string>,
875
+ onEarlySessionId?: EarlySessionIdCallback,
876
+ ): Promise<ClaudeRunResult> => {
877
+ capturedOnEarlySessionId = onEarlySessionId;
878
+ return { result: "claude reply", sessionId: "sess-2" };
879
+ },
880
+ );
881
+
882
+ const warnMessages: string[] = [];
883
+ const originalWarn = console.warn;
884
+ console.warn = (...args: unknown[]) => {
885
+ warnMessages.push(args.map(String).join(" "));
886
+ };
887
+
888
+ try {
889
+ await expect(
890
+ handleCronRequest(
891
+ { jobId: "j-early-fail", prompt: "hello", silent: true },
892
+ { ...deps, runner, cronRunReporter: reporter },
893
+ ),
894
+ ).resolves.toBeUndefined();
895
+
896
+ expect(typeof capturedOnEarlySessionId).toBe("function");
897
+ capturedOnEarlySessionId?.("early-sess-id-2");
898
+ // Let the rejected recordSessionId promise's .catch handler run.
899
+ await Promise.resolve();
900
+ await Promise.resolve();
901
+ await Promise.resolve();
902
+ } finally {
903
+ console.warn = originalWarn;
904
+ }
905
+
906
+ expect(warnMessages.some((m) => m.includes("recordSessionId failed"))).toBe(
907
+ true,
908
+ );
909
+ });
910
+ });
911
+
912
+ // ─── buildTokenPayload — telemetry + context-stamp extras ────────────────────
913
+
914
+ describe("buildTokenPayload — extras", () => {
915
+ const usage = {
916
+ input_tokens: 10,
917
+ output_tokens: 20,
918
+ cache_read_input_tokens: 30,
919
+ cache_creation_input_tokens: 40,
920
+ };
921
+
922
+ test("without extras the payload shape is unchanged", () => {
923
+ const payload = buildTokenPayload(usage, undefined);
924
+ expect(payload).toEqual({
925
+ inputTokens: 10,
926
+ outputTokens: 20,
927
+ cacheReadTokens: 30,
928
+ cacheCreationTokens: 40,
929
+ });
930
+ });
931
+
932
+ test("telemetry maps firstTurn → contextBaseline and forwards counts and skillUsage", () => {
933
+ const payload = buildTokenPayload(usage, undefined, {
934
+ telemetry: {
935
+ firstTurn: {
936
+ model: "claude-sonnet-4-6",
937
+ messageId: "msg_1",
938
+ inputTokens: 2,
939
+ cacheCreationTokens: 41_415,
940
+ cacheReadTokens: 37_771,
941
+ contextTokens: 79_188,
942
+ },
943
+ turns: 5,
944
+ toolCalls: 3,
945
+ skillUsage: [
946
+ {
947
+ kind: "root",
948
+ name: "root",
949
+ invocations: 0,
950
+ turns: 1,
951
+ inputTokens: 2,
952
+ outputTokens: 10,
953
+ cacheReadTokens: 0,
954
+ cacheCreationTokens: 0,
955
+ invokeContextDelta: null,
956
+ },
957
+ ],
958
+ },
959
+ contextStamp: {
960
+ contextFingerprint: "abc123def456",
961
+ pluginVersion: "1.363.0",
962
+ claudeCodeVersion: "2.1.285",
963
+ },
964
+ });
965
+
966
+ expect(payload.contextBaseline).toEqual({
967
+ model: "claude-sonnet-4-6",
968
+ contextTokens: 79_188,
969
+ inputTokens: 2,
970
+ cacheCreationTokens: 41_415,
971
+ cacheReadTokens: 37_771,
972
+ });
973
+ expect(payload.turns).toBe(5);
974
+ expect(payload.toolCalls).toBe(3);
975
+ expect(payload.skillUsage).toHaveLength(1);
976
+ expect(payload.contextFingerprint).toBe("abc123def456");
977
+ expect(payload.pluginVersion).toBe("1.363.0");
978
+ expect(payload.claudeCodeVersion).toBe("2.1.285");
979
+ });
980
+
981
+ test("telemetry without a firstTurn (resumed session) omits contextBaseline but keeps counts", () => {
982
+ const payload = buildTokenPayload(undefined, undefined, {
983
+ telemetry: { turns: 2, toolCalls: 0, skillUsage: [] },
984
+ });
985
+ expect("contextBaseline" in payload).toBe(false);
986
+ expect("skillUsage" in payload).toBe(false);
987
+ expect(payload.turns).toBe(2);
988
+ expect(payload.toolCalls).toBe(0);
989
+ });
990
+
991
+ test("a stamp with null versions sends only the fingerprint", () => {
992
+ const payload = buildTokenPayload(undefined, undefined, {
993
+ contextStamp: {
994
+ contextFingerprint: "feedfacecafe",
995
+ pluginVersion: null,
996
+ claudeCodeVersion: null,
997
+ },
998
+ });
999
+ expect(payload.contextFingerprint).toBe("feedfacecafe");
1000
+ expect("pluginVersion" in payload).toBe(false);
1001
+ expect("claudeCodeVersion" in payload).toBe(false);
1002
+ });
1003
+ });
1004
+
1005
+ // ─── handleCronRequest — telemetry + context-stamp forwarding (PAU-1.4) ──────
1006
+
1007
+ describe("handleCronRequest — telemetry forwarding", () => {
1008
+ const telemetry: RunTelemetry = {
1009
+ firstTurn: {
1010
+ model: "claude-sonnet-4-6",
1011
+ messageId: "msg_1",
1012
+ inputTokens: 2,
1013
+ cacheCreationTokens: 100,
1014
+ cacheReadTokens: 50,
1015
+ contextTokens: 152,
1016
+ },
1017
+ turns: 4,
1018
+ toolCalls: 2,
1019
+ skillUsage: [],
1020
+ };
1021
+ const stamp: RunContextStamp = {
1022
+ contextFingerprint: "abc123def456",
1023
+ pluginVersion: "1.2.3",
1024
+ claudeCodeVersion: "2.1.0",
1025
+ };
1026
+
1027
+ function recordingReporter(): {
1028
+ reporter: CronRunReporter;
1029
+ completes: Array<{ outcome: string; opts?: RunReportOpts }>;
1030
+ } {
1031
+ const completes: Array<{ outcome: string; opts?: RunReportOpts }> = [];
1032
+ const reporter: CronRunReporter = {
1033
+ async createRun() {
1034
+ return "run-t1";
1035
+ },
1036
+ async completeRun(_c, _r, _t, outcome, opts) {
1037
+ completes.push({ outcome, opts });
1038
+ },
1039
+ async skipRun() {},
1040
+ async recordProgress() {},
1041
+ async recordSessionId() {},
1042
+ };
1043
+ return { reporter, completes };
1044
+ }
1045
+
1046
+ test("completion forwards telemetry and the stamp to completeRun", async () => {
1047
+ const { reporter, completes } = recordingReporter();
1048
+ const runner = mock(
1049
+ async (): Promise<ClaudeRunResult> => ({
1050
+ result: "ok",
1051
+ sessionId: "s1",
1052
+ telemetry,
1053
+ }),
1054
+ );
1055
+ await handleCronRequest(
1056
+ { jobId: "j-tel", prompt: "p", silent: true },
1057
+ { ...deps, runner, cronRunReporter: reporter, contextStamp: () => stamp },
1058
+ );
1059
+ expect(completes).toHaveLength(1);
1060
+ expect(completes[0]?.opts).toMatchObject({
1061
+ turns: 4,
1062
+ toolCalls: 2,
1063
+ contextFingerprint: "abc123def456",
1064
+ pluginVersion: "1.2.3",
1065
+ claudeCodeVersion: "2.1.0",
1066
+ contextBaseline: { model: "claude-sonnet-4-6", contextTokens: 152 },
1067
+ });
1068
+ });
1069
+
1070
+ test("a ClaudeTimeoutError's partialTelemetry is forwarded on the failed completeRun", async () => {
1071
+ const { reporter, completes } = recordingReporter();
1072
+ const runner = mock(async (): Promise<ClaudeRunResult> => {
1073
+ throw new ClaudeTimeoutError(
1074
+ 1000,
1075
+ "ceiling",
1076
+ undefined,
1077
+ undefined,
1078
+ telemetry,
1079
+ );
1080
+ });
1081
+ await handleCronRequest(
1082
+ { jobId: "j-tel-to", prompt: "p", silent: true },
1083
+ { ...deps, runner, cronRunReporter: reporter, contextStamp: () => stamp },
1084
+ ).catch(() => {});
1085
+ expect(completes[0]?.outcome).toBe("failed");
1086
+ expect(completes[0]?.opts).toMatchObject({
1087
+ turns: 4,
1088
+ contextFingerprint: "abc123def456",
1089
+ });
1090
+ });
1091
+
1092
+ test("a ClaudeRunError's partialTelemetry is forwarded; tokens are not", async () => {
1093
+ const { reporter, completes } = recordingReporter();
1094
+ const runner = mock(async (): Promise<ClaudeRunResult> => {
1095
+ throw new ClaudeRunError(
1096
+ "boom",
1097
+ undefined,
1098
+ "boom",
1099
+ undefined,
1100
+ undefined,
1101
+ telemetry,
1102
+ );
1103
+ });
1104
+ await handleCronRequest(
1105
+ { jobId: "j-tel-re", prompt: "p", silent: true },
1106
+ { ...deps, runner, cronRunReporter: reporter },
1107
+ ).catch(() => {});
1108
+ expect(completes[0]?.outcome).toBe("failed");
1109
+ expect(completes[0]?.opts?.toolCalls).toBe(2);
1110
+ expect(completes[0]?.opts?.contextFingerprint).toBeUndefined();
1111
+ expect(completes[0]?.opts?.inputTokens).toBeUndefined();
1112
+ });
1113
+
1114
+ test("a streamIncomplete run's telemetry is forwarded on the failed completeRun", async () => {
1115
+ const { reporter, completes } = recordingReporter();
1116
+ const runner = mock(
1117
+ async (): Promise<ClaudeRunResult> => ({
1118
+ result: "",
1119
+ streamIncomplete: true,
1120
+ telemetry,
1121
+ }),
1122
+ );
1123
+ await handleCronRequest(
1124
+ { jobId: "j-tel-si", prompt: "p", silent: true },
1125
+ { ...deps, runner, cronRunReporter: reporter },
1126
+ ).catch(() => {});
1127
+ expect(completes[0]?.outcome).toBe("failed");
1128
+ expect(completes[0]?.opts?.turns).toBe(4);
1129
+ expect(completes[0]?.opts?.toolCalls).toBe(2);
1130
+ });
1131
+
1132
+ test("without a contextStamp dep nothing extra is reported and the run does not error", async () => {
1133
+ const { reporter, completes } = recordingReporter();
1134
+ const runner = mock(
1135
+ async (): Promise<ClaudeRunResult> => ({ result: "ok" }),
1136
+ );
1137
+ await handleCronRequest(
1138
+ { jobId: "j-tel-none", prompt: "p", silent: true },
1139
+ { ...deps, runner, cronRunReporter: reporter },
1140
+ );
1141
+ expect(completes[0]?.outcome).toBe("completed");
1142
+ expect(completes[0]?.opts).not.toHaveProperty("contextFingerprint");
1143
+ expect(completes[0]?.opts).not.toHaveProperty("turns");
1144
+ expect(completes[0]?.opts).not.toHaveProperty("contextBaseline");
1145
+ });
1146
+
1147
+ test("a throwing contextStamp reader is warn-logged and the run proceeds unstamped", async () => {
1148
+ const { reporter, completes } = recordingReporter();
1149
+ const runner = mock(
1150
+ async (): Promise<ClaudeRunResult> => ({ result: "ok", telemetry }),
1151
+ );
1152
+ const warnings: string[] = [];
1153
+ const originalWarn = console.warn;
1154
+ console.warn = (...a: unknown[]) => {
1155
+ warnings.push(a.map(String).join(" "));
1156
+ };
1157
+ try {
1158
+ await handleCronRequest(
1159
+ { jobId: "j-tel-throw", prompt: "p", silent: true },
1160
+ {
1161
+ ...deps,
1162
+ runner,
1163
+ cronRunReporter: reporter,
1164
+ contextStamp: () => {
1165
+ throw new Error("fs boom");
1166
+ },
1167
+ },
1168
+ );
1169
+ } finally {
1170
+ console.warn = originalWarn;
1171
+ }
1172
+ expect(warnings.some((w) => w.includes("context stamp failed"))).toBe(true);
1173
+ expect(completes[0]?.outcome).toBe("completed");
1174
+ expect(completes[0]?.opts?.turns).toBe(4);
1175
+ expect(completes[0]?.opts?.contextFingerprint).toBeUndefined();
1176
+ });
1177
+ });