@app-vitals/agent-runtime 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +38 -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,1371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for cron-handler.ts and the POST /cron endpoint in health.ts.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: inject all deps. No real Slack or Claude calls.
|
|
5
|
+
*
|
|
6
|
+
* - runner: mock function
|
|
7
|
+
* - slack: mock WebClient with chat.postMessage and conversations.open
|
|
8
|
+
* - formatter: identity function (no markdown conversion)
|
|
9
|
+
* - CronRunReporter tests spin up their own local Bun.serve() reporter mock
|
|
10
|
+
* - HTTP tests: real Bun.serve() via startHealthServer, hit with fetch() —
|
|
11
|
+
* createHealthApp() only owns GET /health today, not /cron or /stats, so
|
|
12
|
+
* there's no in-process app.request() seam for this route yet. This is a
|
|
13
|
+
* known boundary violation, tracked as a follow-on task to expand
|
|
14
|
+
* createHealthApp() to own /cron so these HTTP assertions can move to
|
|
15
|
+
* app.request().
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
19
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
|
|
23
|
+
import type { WebClient } from "@slack/web-api";
|
|
24
|
+
import type { Server } from "bun";
|
|
25
|
+
import type { ModelUsage, TokenUsage } from "./claude.ts";
|
|
26
|
+
import { ClaudeTimeoutError } from "./claude.ts";
|
|
27
|
+
import { FixedClock } from "./clock.ts";
|
|
28
|
+
import { reportCronFailure } from "./cron-failure-reporter.ts";
|
|
29
|
+
import { handleCronRequest, ValidationError } from "./cron-handler.ts";
|
|
30
|
+
import { HttpCronRunReporter } from "./cron-run-reporter.ts";
|
|
31
|
+
import { startHealthServer } from "./health.ts";
|
|
32
|
+
|
|
33
|
+
// ─── Shared mocks ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const mockPostMessage = mock(() =>
|
|
36
|
+
Promise.resolve({ ok: true, ts: "1234567890.000001" }),
|
|
37
|
+
);
|
|
38
|
+
const mockConversationsOpen = mock(() =>
|
|
39
|
+
Promise.resolve({ channel: { id: "D_DM_CHANNEL" } }),
|
|
40
|
+
);
|
|
41
|
+
const mockReactionsAdd = mock(() => Promise.resolve({ ok: true }));
|
|
42
|
+
const mockFilesUploadV2 = mock(() => Promise.resolve({ ok: true }));
|
|
43
|
+
const mockSlack = {
|
|
44
|
+
chat: { postMessage: mockPostMessage },
|
|
45
|
+
conversations: { open: mockConversationsOpen },
|
|
46
|
+
reactions: { add: mockReactionsAdd },
|
|
47
|
+
files: { uploadV2: mockFilesUploadV2 },
|
|
48
|
+
} as unknown as WebClient;
|
|
49
|
+
|
|
50
|
+
const mockRunner = mock(
|
|
51
|
+
(): Promise<{
|
|
52
|
+
result: string;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
usage?: TokenUsage;
|
|
55
|
+
totalCostUsd?: number;
|
|
56
|
+
modelUsage?: ModelUsage;
|
|
57
|
+
streamIncomplete?: boolean;
|
|
58
|
+
}> => Promise.resolve({ result: "claude reply", sessionId: "sess-1" }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const deps = {
|
|
62
|
+
slack: mockSlack,
|
|
63
|
+
runner: mockRunner,
|
|
64
|
+
formatter: (text: string) => text, // identity — no markdown conversion in tests
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
mockPostMessage.mockClear();
|
|
69
|
+
mockConversationsOpen.mockClear();
|
|
70
|
+
mockRunner.mockClear();
|
|
71
|
+
mockReactionsAdd.mockClear();
|
|
72
|
+
mockFilesUploadV2.mockClear();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe("handleCronRequest — validation", () => {
|
|
78
|
+
test("throws ValidationError when prompt is empty string", async () => {
|
|
79
|
+
await expect(
|
|
80
|
+
handleCronRequest({ jobId: "j1", prompt: "" }, deps),
|
|
81
|
+
).rejects.toBeInstanceOf(ValidationError);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws ValidationError when neither channel nor user is provided (and not silent)", async () => {
|
|
85
|
+
await expect(
|
|
86
|
+
handleCronRequest({ jobId: "j1", prompt: "hello" }, deps),
|
|
87
|
+
).rejects.toBeInstanceOf(ValidationError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("does not throw when silent=true and no channel/user", async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
handleCronRequest({ jobId: "j1", prompt: "hello", silent: true }, deps),
|
|
93
|
+
).resolves.toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("runs runner but skips posting when silent=true", async () => {
|
|
97
|
+
await handleCronRequest(
|
|
98
|
+
{ jobId: "j1", prompt: "hello", silent: true },
|
|
99
|
+
deps,
|
|
100
|
+
);
|
|
101
|
+
expect(mockRunner).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── Channel delivery ─────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("handleCronRequest — channel delivery", () => {
|
|
109
|
+
test("posts result to channel", async () => {
|
|
110
|
+
mockRunner.mockResolvedValueOnce({ result: "the report", sessionId: "s1" });
|
|
111
|
+
|
|
112
|
+
await handleCronRequest(
|
|
113
|
+
{ jobId: "daily", prompt: "Summarise the day", channel: "C-REPORTS" },
|
|
114
|
+
deps,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(mockRunner).toHaveBeenCalledTimes(1);
|
|
118
|
+
const runArg = (mockRunner.mock.calls as unknown as string[][])[0][0];
|
|
119
|
+
expect(runArg).toContain("daily");
|
|
120
|
+
expect(runArg).toContain("Summarise the day");
|
|
121
|
+
|
|
122
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(
|
|
124
|
+
(mockPostMessage.mock.calls as unknown as unknown[][])[0][0],
|
|
125
|
+
).toMatchObject({
|
|
126
|
+
channel: "C-REPORTS",
|
|
127
|
+
text: "the report",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("prefers channel over user when both are provided", async () => {
|
|
132
|
+
await handleCronRequest(
|
|
133
|
+
{ jobId: "dual", prompt: "hello", channel: "C-MAIN", user: "U-SOMEONE" },
|
|
134
|
+
deps,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(
|
|
139
|
+
(mockPostMessage.mock.calls as unknown as unknown[][])[0][0],
|
|
140
|
+
).toMatchObject({ channel: "C-MAIN" });
|
|
141
|
+
expect(mockConversationsOpen).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("calls onPost callback with channel and ts after posting", async () => {
|
|
145
|
+
const onPost = mock((_ch: string, _ts: string) => {});
|
|
146
|
+
await handleCronRequest(
|
|
147
|
+
{ jobId: "j1", prompt: "hello", channel: "C-TEST" },
|
|
148
|
+
{ ...deps, onPost },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(onPost).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(onPost.mock.calls[0]).toEqual(["C-TEST", "1234567890.000001"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("calls onSession callback when sessionId is returned", async () => {
|
|
156
|
+
const onSession = mock((_ch: string, _ts: string, _sid: string) => {});
|
|
157
|
+
mockRunner.mockResolvedValueOnce({
|
|
158
|
+
result: "reply",
|
|
159
|
+
sessionId: "sess-xyz",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await handleCronRequest(
|
|
163
|
+
{ jobId: "j1", prompt: "hello", channel: "C-TEST" },
|
|
164
|
+
{ ...deps, onSession },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(onSession).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(onSession.mock.calls[0]).toEqual([
|
|
169
|
+
"C-TEST",
|
|
170
|
+
"1234567890.000001",
|
|
171
|
+
"sess-xyz",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("formatter applied to Claude result before posting", async () => {
|
|
176
|
+
const upperFormatter = (text: string) => text.toUpperCase();
|
|
177
|
+
mockRunner.mockResolvedValueOnce({
|
|
178
|
+
result: "lower case result",
|
|
179
|
+
sessionId: "s1",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await handleCronRequest(
|
|
183
|
+
{ jobId: "fmt-job", prompt: "run", channel: "C-TEST" },
|
|
184
|
+
{ ...deps, formatter: upperFormatter },
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(
|
|
189
|
+
(mockPostMessage.mock.calls as unknown as unknown[][])[0][0],
|
|
190
|
+
).toMatchObject({
|
|
191
|
+
channel: "C-TEST",
|
|
192
|
+
text: "LOWER CASE RESULT",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not call onPost when postMessage returns no ts", async () => {
|
|
197
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally omitting ts
|
|
198
|
+
mockPostMessage.mockResolvedValueOnce({ ok: true } as any); // no ts
|
|
199
|
+
mockRunner.mockResolvedValueOnce({ result: "report", sessionId: "s1" });
|
|
200
|
+
const onPost = mock(() => {});
|
|
201
|
+
|
|
202
|
+
await handleCronRequest(
|
|
203
|
+
{ jobId: "daily", prompt: "run", channel: "C-X" },
|
|
204
|
+
{ ...deps, onPost },
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(onPost).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── User (DM) delivery ───────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe("handleCronRequest — user DM delivery", () => {
|
|
214
|
+
test("opens DM and posts when only user is provided", async () => {
|
|
215
|
+
mockRunner.mockResolvedValueOnce({ result: "dm reply", sessionId: "s2" });
|
|
216
|
+
|
|
217
|
+
await handleCronRequest(
|
|
218
|
+
{ jobId: "dm-job", prompt: "check in", user: "U-DAN" },
|
|
219
|
+
deps,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(mockConversationsOpen).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(
|
|
224
|
+
(mockConversationsOpen.mock.calls as unknown as unknown[][])[0][0],
|
|
225
|
+
).toMatchObject({ users: "U-DAN" });
|
|
226
|
+
|
|
227
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(
|
|
229
|
+
(mockPostMessage.mock.calls as unknown as unknown[][])[0][0],
|
|
230
|
+
).toMatchObject({
|
|
231
|
+
channel: "D_DM_CHANNEL",
|
|
232
|
+
text: "dm reply",
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("calls onPost with DM channel after DM post", async () => {
|
|
237
|
+
const onPost = mock((_ch: string, _ts: string) => {});
|
|
238
|
+
await handleCronRequest(
|
|
239
|
+
{ jobId: "j1", prompt: "hello", user: "U-DAN" },
|
|
240
|
+
{ ...deps, onPost },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(onPost).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(onPost.mock.calls[0][0]).toBe("D_DM_CHANNEL");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("throws when conversations.open returns no channel id", async () => {
|
|
248
|
+
// Pre-run resolution (STC-1.3) also calls conversations.open before the
|
|
249
|
+
// run — queue a null response for both that pre-run attempt and the
|
|
250
|
+
// post-run fallback so DM delivery still fails end-to-end.
|
|
251
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally null channel
|
|
252
|
+
mockConversationsOpen.mockResolvedValueOnce({ channel: null } as any);
|
|
253
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally null channel
|
|
254
|
+
mockConversationsOpen.mockResolvedValueOnce({ channel: null } as any);
|
|
255
|
+
|
|
256
|
+
await expect(
|
|
257
|
+
handleCronRequest(
|
|
258
|
+
{ jobId: "dm-fail", prompt: "test", user: "U-BAD" },
|
|
259
|
+
deps,
|
|
260
|
+
),
|
|
261
|
+
).rejects.toThrow();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("calls onPost AND onSession for DM posts", async () => {
|
|
265
|
+
mockRunner.mockResolvedValueOnce({
|
|
266
|
+
result: "digest",
|
|
267
|
+
sessionId: "sess-dm-1",
|
|
268
|
+
});
|
|
269
|
+
const onPost = mock(() => {});
|
|
270
|
+
const onSession = mock(() => {});
|
|
271
|
+
|
|
272
|
+
await handleCronRequest(
|
|
273
|
+
{ jobId: "dm-job", prompt: "What's on the agenda?", user: "U-TARGET" },
|
|
274
|
+
{ ...deps, onPost, onSession },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(onPost).toHaveBeenCalledTimes(1);
|
|
278
|
+
expect(onPost).toHaveBeenCalledWith("D_DM_CHANNEL", "1234567890.000001");
|
|
279
|
+
expect(onSession).toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
281
|
+
"D_DM_CHANNEL",
|
|
282
|
+
"1234567890.000001",
|
|
283
|
+
"sess-dm-1",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─── Silent marker ────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe("handleCronRequest — [silent] marker", () => {
|
|
291
|
+
test("[silent] in result suppresses channel post", async () => {
|
|
292
|
+
mockRunner.mockResolvedValueOnce({
|
|
293
|
+
result: "text [silent]",
|
|
294
|
+
sessionId: "s3",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await handleCronRequest(
|
|
298
|
+
{ jobId: "j1", prompt: "hello", channel: "C-MAIN" },
|
|
299
|
+
deps,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("[silent] in result is IGNORED for DM — DM always gets a reply", async () => {
|
|
306
|
+
mockRunner.mockResolvedValueOnce({
|
|
307
|
+
result: "text [silent]",
|
|
308
|
+
sessionId: "s4",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await handleCronRequest(
|
|
312
|
+
{ jobId: "j1", prompt: "hello", user: "U-DAN" },
|
|
313
|
+
deps,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(mockConversationsOpen).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("silent=true dep flag suppresses post", async () => {
|
|
321
|
+
mockRunner.mockResolvedValueOnce({ result: "result", sessionId: "s5" });
|
|
322
|
+
|
|
323
|
+
await handleCronRequest(
|
|
324
|
+
{ jobId: "j1", prompt: "hello", channel: "C-MAIN", silent: true },
|
|
325
|
+
deps,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("silent=true dep flag also suppresses DM posts", async () => {
|
|
332
|
+
mockRunner.mockResolvedValueOnce({ result: "result", sessionId: "s6" });
|
|
333
|
+
|
|
334
|
+
await handleCronRequest(
|
|
335
|
+
{ jobId: "j1", prompt: "hello", user: "U-DAN", silent: true },
|
|
336
|
+
deps,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
expect(mockConversationsOpen).not.toHaveBeenCalled();
|
|
340
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ─── onSession callback ───────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
describe("handleCronRequest — onSession callback", () => {
|
|
347
|
+
test("calls onSession with channel, ts, and sessionId after channel post", async () => {
|
|
348
|
+
mockRunner.mockResolvedValueOnce({
|
|
349
|
+
result: "report",
|
|
350
|
+
sessionId: "sess-cron-1",
|
|
351
|
+
});
|
|
352
|
+
const onSession = mock(() => {});
|
|
353
|
+
|
|
354
|
+
await handleCronRequest(
|
|
355
|
+
{ jobId: "daily", prompt: "Summarise the day", channel: "C-REPORTS" },
|
|
356
|
+
{ ...deps, onSession },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(onSession).toHaveBeenCalledTimes(1);
|
|
360
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
361
|
+
"C-REPORTS",
|
|
362
|
+
"1234567890.000001",
|
|
363
|
+
"sess-cron-1",
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("does not call onSession when sessionId is absent", async () => {
|
|
368
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally omitting sessionId
|
|
369
|
+
mockRunner.mockResolvedValueOnce({ result: "report" } as any);
|
|
370
|
+
const onSession = mock(() => {});
|
|
371
|
+
|
|
372
|
+
await handleCronRequest(
|
|
373
|
+
{ jobId: "daily", prompt: "Summarise the day", channel: "C-REPORTS" },
|
|
374
|
+
{ ...deps, onSession },
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
expect(onSession).not.toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("does not call onSession when silent", async () => {
|
|
381
|
+
const onSession = mock(() => {});
|
|
382
|
+
|
|
383
|
+
await handleCronRequest(
|
|
384
|
+
{ jobId: "quiet", prompt: "run", channel: "C-X", silent: true },
|
|
385
|
+
{ ...deps, onSession },
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
expect(onSession).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ─── Marker dispatch ──────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
describe("handleCronRequest — marker dispatch", () => {
|
|
395
|
+
test("[react:thumbsup] in response → reactions.add called with channel, timestamp, name", async () => {
|
|
396
|
+
mockRunner.mockResolvedValueOnce({
|
|
397
|
+
result: "good news [react:thumbsup]",
|
|
398
|
+
sessionId: "s1",
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await handleCronRequest(
|
|
402
|
+
{ jobId: "react-job", prompt: "run", channel: "C-REPORTS" },
|
|
403
|
+
deps,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
expect(mockReactionsAdd).toHaveBeenCalledTimes(1);
|
|
407
|
+
expect(mockReactionsAdd).toHaveBeenCalledWith({
|
|
408
|
+
channel: "C-REPORTS",
|
|
409
|
+
timestamp: "1234567890.000001",
|
|
410
|
+
name: "thumbsup",
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("[react:emoji1,emoji2] → reactions.add called once per emoji", async () => {
|
|
415
|
+
mockRunner.mockResolvedValueOnce({
|
|
416
|
+
result: "done [react:white_check_mark,tada]",
|
|
417
|
+
sessionId: "s1",
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await handleCronRequest(
|
|
421
|
+
{ jobId: "react-multi", prompt: "run", channel: "C-REPORTS" },
|
|
422
|
+
deps,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
expect(mockReactionsAdd).toHaveBeenCalledTimes(2);
|
|
426
|
+
expect(mockReactionsAdd).toHaveBeenCalledWith({
|
|
427
|
+
channel: "C-REPORTS",
|
|
428
|
+
timestamp: "1234567890.000001",
|
|
429
|
+
name: "white_check_mark",
|
|
430
|
+
});
|
|
431
|
+
expect(mockReactionsAdd).toHaveBeenCalledWith({
|
|
432
|
+
channel: "C-REPORTS",
|
|
433
|
+
timestamp: "1234567890.000001",
|
|
434
|
+
name: "tada",
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("[react:thumbsup] when postMessage returns no ts → reactions.add not called", async () => {
|
|
439
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally omitting ts
|
|
440
|
+
mockPostMessage.mockResolvedValueOnce({ ok: true } as any); // no ts
|
|
441
|
+
mockRunner.mockResolvedValueOnce({
|
|
442
|
+
result: "good news [react:thumbsup]",
|
|
443
|
+
sessionId: "s1",
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await handleCronRequest(
|
|
447
|
+
{ jobId: "react-nots", prompt: "run", channel: "C-REPORTS" },
|
|
448
|
+
deps,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
expect(mockReactionsAdd).not.toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("[upload:/path/to/file] → files.uploadV2 called with the correct file path", async () => {
|
|
455
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "cron-upload-test-"));
|
|
456
|
+
const filePath = join(tmpDir, "report.txt");
|
|
457
|
+
writeFileSync(filePath, "report content");
|
|
458
|
+
|
|
459
|
+
mockRunner.mockResolvedValueOnce({
|
|
460
|
+
result: `here is the report [upload:${filePath}]`,
|
|
461
|
+
sessionId: "s1",
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await handleCronRequest(
|
|
465
|
+
{ jobId: "upload-job", prompt: "run", channel: "C-REPORTS" },
|
|
466
|
+
deps,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
expect(mockFilesUploadV2).toHaveBeenCalledTimes(1);
|
|
470
|
+
const uploadCall = (
|
|
471
|
+
mockFilesUploadV2.mock.calls as unknown as unknown[][]
|
|
472
|
+
)[0][0] as {
|
|
473
|
+
channel_id: string;
|
|
474
|
+
filename: string;
|
|
475
|
+
};
|
|
476
|
+
expect(uploadCall.channel_id).toBe("C-REPORTS");
|
|
477
|
+
expect(uploadCall.filename).toBe("report.txt");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("[speak:hello world] with synthesizeSpeechFn injected → fn called and audio uploaded", async () => {
|
|
481
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "cron-speak-test-"));
|
|
482
|
+
const audioPath = join(tmpDir, "response.mp3");
|
|
483
|
+
writeFileSync(audioPath, Buffer.from("fake-audio-data"));
|
|
484
|
+
|
|
485
|
+
const mockSynthesize = mock(() => Promise.resolve(audioPath));
|
|
486
|
+
mockRunner.mockResolvedValueOnce({
|
|
487
|
+
result: "here is audio [speak:hello world]",
|
|
488
|
+
sessionId: "s1",
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
await handleCronRequest(
|
|
492
|
+
{ jobId: "speak-job", prompt: "run", channel: "C-VOICE" },
|
|
493
|
+
{
|
|
494
|
+
...deps,
|
|
495
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
496
|
+
voiceConfig: { groqApiKey: "test-key" },
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
|
501
|
+
expect((mockSynthesize.mock.calls as unknown as string[][])[0][0]).toBe(
|
|
502
|
+
"hello world",
|
|
503
|
+
);
|
|
504
|
+
expect(mockFilesUploadV2).toHaveBeenCalledTimes(1);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("[speak:hello] without synthesizeSpeechFn injected → no throw, no upload", async () => {
|
|
508
|
+
mockRunner.mockResolvedValueOnce({
|
|
509
|
+
result: "here is audio [speak:hello]",
|
|
510
|
+
sessionId: "s1",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await expect(
|
|
514
|
+
handleCronRequest(
|
|
515
|
+
{ jobId: "speak-nofn", prompt: "run", channel: "C-VOICE" },
|
|
516
|
+
deps, // no synthesizeSpeechFn
|
|
517
|
+
),
|
|
518
|
+
).resolves.toBeUndefined();
|
|
519
|
+
|
|
520
|
+
expect(mockFilesUploadV2).not.toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("marker tags are stripped from visible message text before posting", async () => {
|
|
524
|
+
mockRunner.mockResolvedValueOnce({
|
|
525
|
+
result: "the report [react:thumbsup]",
|
|
526
|
+
sessionId: "s1",
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
await handleCronRequest(
|
|
530
|
+
{ jobId: "strip-job", prompt: "run", channel: "C-REPORTS" },
|
|
531
|
+
deps,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
535
|
+
const postArg = (
|
|
536
|
+
mockPostMessage.mock.calls as unknown as unknown[][]
|
|
537
|
+
)[0][0] as {
|
|
538
|
+
text: string;
|
|
539
|
+
};
|
|
540
|
+
expect(postArg.text).not.toContain("[react:thumbsup]");
|
|
541
|
+
expect(postArg.text).toContain("the report");
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ─── Runner error ─────────────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
describe("handleCronRequest — runner error", () => {
|
|
548
|
+
test("propagates error from runner", async () => {
|
|
549
|
+
mockRunner.mockRejectedValueOnce(new Error("claude crashed"));
|
|
550
|
+
|
|
551
|
+
await expect(
|
|
552
|
+
handleCronRequest(
|
|
553
|
+
{ jobId: "err-job", prompt: "do stuff", channel: "C-X" },
|
|
554
|
+
deps,
|
|
555
|
+
),
|
|
556
|
+
).rejects.toThrow("claude crashed");
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// ─── CronRunReporter integration ─────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
const FIXED_TIME = new Date("2026-01-01T00:00:00.000Z");
|
|
563
|
+
|
|
564
|
+
// Stub server helpers for cron-handler reporter tests
|
|
565
|
+
|
|
566
|
+
interface ReporterStubState {
|
|
567
|
+
requests: Array<{ method: string; url: string; body: unknown }>;
|
|
568
|
+
runIdToReturn: string;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function startReporterStub(
|
|
572
|
+
port: number,
|
|
573
|
+
state: ReporterStubState,
|
|
574
|
+
// biome-ignore lint/suspicious/noExplicitAny: Server type param varies by bun version
|
|
575
|
+
): ReturnType<typeof Bun.serve<any>> {
|
|
576
|
+
return Bun.serve({
|
|
577
|
+
port,
|
|
578
|
+
fetch: async (req) => {
|
|
579
|
+
const body = await req.json().catch(() => null);
|
|
580
|
+
state.requests.push({ method: req.method, url: req.url, body });
|
|
581
|
+
|
|
582
|
+
if (req.method === "POST") {
|
|
583
|
+
return new Response(
|
|
584
|
+
JSON.stringify({ run: { id: state.runIdToReturn } }),
|
|
585
|
+
{ status: 201, headers: { "Content-Type": "application/json" } },
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return new Response(
|
|
589
|
+
JSON.stringify({ run: { id: state.runIdToReturn } }),
|
|
590
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
591
|
+
);
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
describe("handleCronRequest — CronRunReporter", () => {
|
|
597
|
+
let tmpDir: string;
|
|
598
|
+
// biome-ignore lint/suspicious/noExplicitAny: Server type param varies by bun version
|
|
599
|
+
let reporterServer: ReturnType<typeof Bun.serve<any>> | undefined;
|
|
600
|
+
let reporterState: ReporterStubState;
|
|
601
|
+
let reporterBaseUrl: string;
|
|
602
|
+
const AGENT_ID = "test-agent-id";
|
|
603
|
+
|
|
604
|
+
function makeHttpReporter() {
|
|
605
|
+
return new HttpCronRunReporter({
|
|
606
|
+
apiUrl: reporterBaseUrl,
|
|
607
|
+
agentId: AGENT_ID,
|
|
608
|
+
apiKey: "test-key",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
beforeEach(() => {
|
|
613
|
+
tmpDir = mkdtempSync(join(tmpdir(), "cron-reporter-test-"));
|
|
614
|
+
mkdirSync(join(tmpDir, "shipwright", "scripts"), { recursive: true });
|
|
615
|
+
reporterState = { requests: [], runIdToReturn: "run-test-1" };
|
|
616
|
+
reporterServer = startReporterStub(0, reporterState);
|
|
617
|
+
reporterBaseUrl = `http://localhost:${reporterServer.port}`;
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
afterEach(async () => {
|
|
621
|
+
await reporterServer?.stop(true);
|
|
622
|
+
reporterServer = undefined;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("no reporter dep — existing tests unaffected (no-op)", async () => {
|
|
626
|
+
mockRunner.mockResolvedValueOnce({ result: "reply", sessionId: "s1" });
|
|
627
|
+
// no cronRunReporter in deps — should not throw
|
|
628
|
+
await expect(
|
|
629
|
+
handleCronRequest({ jobId: "j1", prompt: "hello", channel: "C-X" }, deps),
|
|
630
|
+
).resolves.toBeUndefined();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("CREATE is called at run start with startedAt before runner executes", async () => {
|
|
634
|
+
mockRunner.mockResolvedValueOnce({
|
|
635
|
+
result: "reply",
|
|
636
|
+
sessionId: "s1",
|
|
637
|
+
usage: {
|
|
638
|
+
input_tokens: 10,
|
|
639
|
+
output_tokens: 5,
|
|
640
|
+
cache_read_input_tokens: 0,
|
|
641
|
+
cache_creation_input_tokens: 0,
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const reporter = makeHttpReporter();
|
|
646
|
+
await handleCronRequest(
|
|
647
|
+
{ jobId: "create-test", prompt: "hello", channel: "C-X" },
|
|
648
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
// First request must be a POST (CREATE)
|
|
652
|
+
expect(reporterState.requests.length).toBeGreaterThanOrEqual(2);
|
|
653
|
+
expect(reporterState.requests[0].method).toBe("POST");
|
|
654
|
+
expect(reporterState.requests[0].url).toContain(
|
|
655
|
+
`/agents/${AGENT_ID}/crons/create-test/runs`,
|
|
656
|
+
);
|
|
657
|
+
const createBody = reporterState.requests[0].body as Record<
|
|
658
|
+
string,
|
|
659
|
+
unknown
|
|
660
|
+
>;
|
|
661
|
+
expect(createBody.startedAt).toBe(FIXED_TIME.toISOString());
|
|
662
|
+
expect(Object.keys(createBody)).toEqual(["startedAt"]);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("PATCH called after successful completion with outcome='completed' + token data", async () => {
|
|
666
|
+
mockRunner.mockResolvedValueOnce({
|
|
667
|
+
result: "the reply",
|
|
668
|
+
sessionId: "s1",
|
|
669
|
+
usage: {
|
|
670
|
+
input_tokens: 100,
|
|
671
|
+
output_tokens: 50,
|
|
672
|
+
cache_read_input_tokens: 20,
|
|
673
|
+
cache_creation_input_tokens: 10,
|
|
674
|
+
},
|
|
675
|
+
totalCostUsd: 0.005,
|
|
676
|
+
modelUsage: {
|
|
677
|
+
"claude-sonnet-4-6": {
|
|
678
|
+
inputTokens: 100,
|
|
679
|
+
outputTokens: 50,
|
|
680
|
+
cacheReadInputTokens: 20,
|
|
681
|
+
cacheCreationInputTokens: 10,
|
|
682
|
+
costUSD: 0.005,
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const reporter = makeHttpReporter();
|
|
688
|
+
await handleCronRequest(
|
|
689
|
+
{ jobId: "complete-test", prompt: "hello", channel: "C-REPORTS" },
|
|
690
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
// Second request must be a PATCH (complete)
|
|
694
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
695
|
+
expect(patchReq).toBeDefined();
|
|
696
|
+
expect(patchReq?.url).toContain(
|
|
697
|
+
`/agents/${AGENT_ID}/crons/complete-test/runs/run-test-1`,
|
|
698
|
+
);
|
|
699
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
700
|
+
expect(patchBody.outcome).toBe("completed");
|
|
701
|
+
expect(patchBody.completedAt).toBeDefined();
|
|
702
|
+
expect(patchBody.inputTokens).toBe(100);
|
|
703
|
+
expect(patchBody.outputTokens).toBe(50);
|
|
704
|
+
expect(patchBody.cacheReadTokens).toBe(20);
|
|
705
|
+
expect(patchBody.cacheCreationTokens).toBe(10);
|
|
706
|
+
const modelBreakdown = patchBody.modelBreakdown as Array<
|
|
707
|
+
Record<string, unknown>
|
|
708
|
+
>;
|
|
709
|
+
expect(modelBreakdown).toHaveLength(1);
|
|
710
|
+
expect(modelBreakdown[0].costUsd).toBe(0.005);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("token data sent correctly when multiple models used (modelBreakdown preserved)", async () => {
|
|
714
|
+
mockRunner.mockResolvedValueOnce({
|
|
715
|
+
result: "the reply",
|
|
716
|
+
sessionId: "s1",
|
|
717
|
+
usage: {
|
|
718
|
+
input_tokens: 300,
|
|
719
|
+
output_tokens: 250,
|
|
720
|
+
cache_read_input_tokens: 0,
|
|
721
|
+
cache_creation_input_tokens: 0,
|
|
722
|
+
},
|
|
723
|
+
totalCostUsd: 0.012,
|
|
724
|
+
modelUsage: {
|
|
725
|
+
"claude-sonnet-4-6": {
|
|
726
|
+
inputTokens: 100,
|
|
727
|
+
outputTokens: 50,
|
|
728
|
+
cacheReadInputTokens: 0,
|
|
729
|
+
cacheCreationInputTokens: 0,
|
|
730
|
+
costUSD: 0.002,
|
|
731
|
+
},
|
|
732
|
+
"claude-opus-4-8": {
|
|
733
|
+
inputTokens: 200,
|
|
734
|
+
outputTokens: 200,
|
|
735
|
+
cacheReadInputTokens: 0,
|
|
736
|
+
cacheCreationInputTokens: 0,
|
|
737
|
+
costUSD: 0.01,
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const reporter = makeHttpReporter();
|
|
743
|
+
await handleCronRequest(
|
|
744
|
+
{ jobId: "dominant-model-test", prompt: "hello", channel: "C-REPORTS" },
|
|
745
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
749
|
+
expect(patchReq).toBeDefined();
|
|
750
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
751
|
+
// Token data present, plus per-model costUsd passed through directly from costUSD
|
|
752
|
+
expect(patchBody.inputTokens).toBe(300);
|
|
753
|
+
expect(patchBody.outputTokens).toBe(250);
|
|
754
|
+
const modelBreakdown = patchBody.modelBreakdown as Array<
|
|
755
|
+
Record<string, unknown>
|
|
756
|
+
>;
|
|
757
|
+
expect(modelBreakdown).toHaveLength(2);
|
|
758
|
+
const byModel = Object.fromEntries(modelBreakdown.map((m) => [m.model, m]));
|
|
759
|
+
expect(byModel["claude-sonnet-4-6"].costUsd).toBe(0.002);
|
|
760
|
+
expect(byModel["claude-opus-4-8"].costUsd).toBe(0.01);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("PATCH called with outcome='failed' + error message when runner throws", async () => {
|
|
764
|
+
mockRunner.mockRejectedValueOnce(new Error("runner exploded"));
|
|
765
|
+
|
|
766
|
+
const reporter = makeHttpReporter();
|
|
767
|
+
await expect(
|
|
768
|
+
handleCronRequest(
|
|
769
|
+
{ jobId: "error-test", prompt: "hello", channel: "C-X" },
|
|
770
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
771
|
+
),
|
|
772
|
+
).rejects.toThrow("runner exploded");
|
|
773
|
+
|
|
774
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
775
|
+
expect(patchReq).toBeDefined();
|
|
776
|
+
expect(patchReq?.url).toContain(
|
|
777
|
+
`/agents/${AGENT_ID}/crons/error-test/runs/run-test-1`,
|
|
778
|
+
);
|
|
779
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
780
|
+
expect(patchBody.outcome).toBe("failed");
|
|
781
|
+
expect(patchBody.error).toBe("runner exploded");
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("streamIncomplete:true → run recorded 'failed', no Slack post (CSU-1.1 regression)", async () => {
|
|
785
|
+
mockRunner.mockResolvedValueOnce({
|
|
786
|
+
result: "",
|
|
787
|
+
streamIncomplete: true,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
const reporter = makeHttpReporter();
|
|
791
|
+
await expect(
|
|
792
|
+
handleCronRequest(
|
|
793
|
+
{ jobId: "incomplete-test", prompt: "hello", channel: "C-X" },
|
|
794
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
795
|
+
),
|
|
796
|
+
).rejects.toThrow("stream ended without a terminal result event");
|
|
797
|
+
|
|
798
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
799
|
+
|
|
800
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
801
|
+
expect(patchReq).toBeDefined();
|
|
802
|
+
expect(patchReq?.url).toContain(
|
|
803
|
+
`/agents/${AGENT_ID}/crons/incomplete-test/runs/run-test-1`,
|
|
804
|
+
);
|
|
805
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
806
|
+
expect(patchBody.outcome).toBe("failed");
|
|
807
|
+
expect(patchBody.error).toContain(
|
|
808
|
+
"stream ended without a terminal result event",
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("preCheck not found → skipRun called with skipped:true, skipReason:'preCheck:not-found'", async () => {
|
|
813
|
+
const reporter = makeHttpReporter();
|
|
814
|
+
|
|
815
|
+
await handleCronRequest(
|
|
816
|
+
{
|
|
817
|
+
jobId: "missing-precheck",
|
|
818
|
+
prompt: "hello",
|
|
819
|
+
channel: "C-TEST",
|
|
820
|
+
preCheck: "nonexistent-plugin:check.ts",
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
...deps,
|
|
824
|
+
pluginCacheDir: tmpDir,
|
|
825
|
+
cronRunReporter: reporter,
|
|
826
|
+
clock: FixedClock(FIXED_TIME),
|
|
827
|
+
},
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
expect(mockRunner).not.toHaveBeenCalled();
|
|
831
|
+
|
|
832
|
+
// Should have a POST (create) and PATCH (skip)
|
|
833
|
+
const postReq = reporterState.requests.find((r) => r.method === "POST");
|
|
834
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
835
|
+
expect(postReq).toBeDefined();
|
|
836
|
+
expect(patchReq).toBeDefined();
|
|
837
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
838
|
+
expect(patchBody.skipped).toBe(true);
|
|
839
|
+
expect(patchBody.skipReason).toBe("preCheck:not-found");
|
|
840
|
+
expect(patchBody.completedAt).toBeDefined();
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("preCheck exit 1 (no output) → skipRun with skipReason:'preCheck:no-output'", async () => {
|
|
844
|
+
const scriptPath = join(
|
|
845
|
+
tmpDir,
|
|
846
|
+
"shipwright",
|
|
847
|
+
"scripts",
|
|
848
|
+
"check-no-output.ts",
|
|
849
|
+
);
|
|
850
|
+
writeFileSync(scriptPath, "process.exit(1);", { mode: 0o755 });
|
|
851
|
+
|
|
852
|
+
const reporter = makeHttpReporter();
|
|
853
|
+
await handleCronRequest(
|
|
854
|
+
{
|
|
855
|
+
jobId: "precheck-no-output",
|
|
856
|
+
prompt: "original",
|
|
857
|
+
channel: "C-TEST",
|
|
858
|
+
preCheck: "shipwright:check-no-output.ts",
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
...deps,
|
|
862
|
+
pluginCacheDir: tmpDir,
|
|
863
|
+
cronRunReporter: reporter,
|
|
864
|
+
clock: FixedClock(FIXED_TIME),
|
|
865
|
+
},
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
expect(mockRunner).not.toHaveBeenCalled();
|
|
869
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
870
|
+
expect(patchReq).toBeDefined();
|
|
871
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
872
|
+
expect(patchBody.skipped).toBe(true);
|
|
873
|
+
expect(patchBody.skipReason).toBe("preCheck:no-output");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("preCheck crash (exit ≥2) → skipRun with skipReason:'preCheck:crash' + error", async () => {
|
|
877
|
+
const scriptPath = join(tmpDir, "shipwright", "scripts", "check-crash.ts");
|
|
878
|
+
writeFileSync(
|
|
879
|
+
scriptPath,
|
|
880
|
+
`process.stderr.write("something blew up\\n"); process.exit(2);`,
|
|
881
|
+
{ mode: 0o755 },
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const reporter = makeHttpReporter();
|
|
885
|
+
await handleCronRequest(
|
|
886
|
+
{
|
|
887
|
+
jobId: "precheck-crash-reporter",
|
|
888
|
+
prompt: "original",
|
|
889
|
+
silent: true,
|
|
890
|
+
preCheck: "shipwright:check-crash.ts",
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
...deps,
|
|
894
|
+
pluginCacheDir: tmpDir,
|
|
895
|
+
cronRunReporter: reporter,
|
|
896
|
+
clock: FixedClock(FIXED_TIME),
|
|
897
|
+
},
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
901
|
+
expect(patchReq).toBeDefined();
|
|
902
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
903
|
+
expect(patchBody.skipped).toBe(true);
|
|
904
|
+
expect(patchBody.skipReason).toBe("preCheck:crash");
|
|
905
|
+
expect(patchBody.error).toBe("something blew up");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("silent=true → completeRun called with outcome:'completed'", async () => {
|
|
909
|
+
mockRunner.mockResolvedValueOnce({
|
|
910
|
+
result: "reply",
|
|
911
|
+
sessionId: "s1",
|
|
912
|
+
usage: {
|
|
913
|
+
input_tokens: 5,
|
|
914
|
+
output_tokens: 3,
|
|
915
|
+
cache_read_input_tokens: 0,
|
|
916
|
+
cache_creation_input_tokens: 0,
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
const reporter = makeHttpReporter();
|
|
921
|
+
await handleCronRequest(
|
|
922
|
+
{ jobId: "silent-job", prompt: "hello", silent: true },
|
|
923
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
927
|
+
expect(patchReq).toBeDefined();
|
|
928
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
929
|
+
expect(patchBody.outcome).toBe("completed");
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test("[silent] marker → completeRun called with outcome:'completed'", async () => {
|
|
933
|
+
mockRunner.mockResolvedValueOnce({
|
|
934
|
+
result: "text [silent]",
|
|
935
|
+
sessionId: "s1",
|
|
936
|
+
usage: {
|
|
937
|
+
input_tokens: 5,
|
|
938
|
+
output_tokens: 3,
|
|
939
|
+
cache_read_input_tokens: 0,
|
|
940
|
+
cache_creation_input_tokens: 0,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const reporter = makeHttpReporter();
|
|
945
|
+
await handleCronRequest(
|
|
946
|
+
{ jobId: "silent-marker-job", prompt: "hello", channel: "C-MAIN" },
|
|
947
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
951
|
+
expect(patchReq).toBeDefined();
|
|
952
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
953
|
+
expect(patchBody.outcome).toBe("completed");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("channel post → completeRun called with outcome:'completed'", async () => {
|
|
957
|
+
mockRunner.mockResolvedValueOnce({
|
|
958
|
+
result: "the report",
|
|
959
|
+
sessionId: "s1",
|
|
960
|
+
usage: {
|
|
961
|
+
input_tokens: 100,
|
|
962
|
+
output_tokens: 50,
|
|
963
|
+
cache_read_input_tokens: 0,
|
|
964
|
+
cache_creation_input_tokens: 0,
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
const reporter = makeHttpReporter();
|
|
969
|
+
await handleCronRequest(
|
|
970
|
+
{ jobId: "channel-job", prompt: "hello", channel: "C-REPORTS" },
|
|
971
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
975
|
+
expect(patchReq).toBeDefined();
|
|
976
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
977
|
+
expect(patchBody.outcome).toBe("completed");
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test("DM post → completeRun called with outcome:'completed'", async () => {
|
|
981
|
+
mockRunner.mockResolvedValueOnce({
|
|
982
|
+
result: "dm reply",
|
|
983
|
+
sessionId: "s1",
|
|
984
|
+
usage: {
|
|
985
|
+
input_tokens: 30,
|
|
986
|
+
output_tokens: 20,
|
|
987
|
+
cache_read_input_tokens: 0,
|
|
988
|
+
cache_creation_input_tokens: 0,
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const reporter = makeHttpReporter();
|
|
993
|
+
await handleCronRequest(
|
|
994
|
+
{ jobId: "dm-job", prompt: "hello", user: "U-DAN" },
|
|
995
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
999
|
+
expect(patchReq).toBeDefined();
|
|
1000
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
1001
|
+
expect(patchBody.outcome).toBe("completed");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// ── Post-completion Slack-delivery-failure dedup contract ──────────────────
|
|
1005
|
+
//
|
|
1006
|
+
// handleCronRequest records the run as "completed" BEFORE attempting Slack
|
|
1007
|
+
// delivery (channel post / DM open / dispatchMarkers). If that delivery
|
|
1008
|
+
// step throws, the run already has a correct "completed" row — the thrown
|
|
1009
|
+
// error must be tagged via markCronRunFailureReported() so that a
|
|
1010
|
+
// subsequent reportCronFailure() call (as index.ts's outer catch performs)
|
|
1011
|
+
// skips creating a spurious duplicate "failed" row for the same tick. These
|
|
1012
|
+
// tests drive handleCronRequest through a real post-completion failure and
|
|
1013
|
+
// then feed the thrown error into the real reportCronFailure() to confirm
|
|
1014
|
+
// the end-to-end cross-module dedup contract, not just the tagging
|
|
1015
|
+
// primitive in isolation.
|
|
1016
|
+
|
|
1017
|
+
test("channel post-completion Slack failure: run recorded 'completed', thrown error tagged so reportCronFailure skips a duplicate 'failed' row", async () => {
|
|
1018
|
+
mockRunner.mockResolvedValueOnce({
|
|
1019
|
+
result: "the report",
|
|
1020
|
+
sessionId: "s1",
|
|
1021
|
+
usage: {
|
|
1022
|
+
input_tokens: 10,
|
|
1023
|
+
output_tokens: 5,
|
|
1024
|
+
cache_read_input_tokens: 0,
|
|
1025
|
+
cache_creation_input_tokens: 0,
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
mockPostMessage.mockRejectedValueOnce(new Error("slack API down"));
|
|
1029
|
+
|
|
1030
|
+
const reporter = makeHttpReporter();
|
|
1031
|
+
const thrown = await handleCronRequest(
|
|
1032
|
+
{ jobId: "slack-fail-channel", prompt: "hello", channel: "C-REPORTS" },
|
|
1033
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
1034
|
+
).then(
|
|
1035
|
+
() => undefined,
|
|
1036
|
+
(err) => err,
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
1040
|
+
expect((thrown as Error).message).toBe("slack API down");
|
|
1041
|
+
|
|
1042
|
+
// The run was already recorded as "completed" before the Slack failure.
|
|
1043
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
1044
|
+
expect(patchReq).toBeDefined();
|
|
1045
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
1046
|
+
expect(patchBody.outcome).toBe("completed");
|
|
1047
|
+
expect(
|
|
1048
|
+
reporterState.requests.filter((r) => r.method === "PATCH"),
|
|
1049
|
+
).toHaveLength(1);
|
|
1050
|
+
|
|
1051
|
+
// Feeding the thrown error into the real reportCronFailure() (the same
|
|
1052
|
+
// call index.ts's outer catch makes) must NOT create a second run —
|
|
1053
|
+
// it must detect the tag and skip.
|
|
1054
|
+
const failureReportCalls: Array<{ requests: number }> = [];
|
|
1055
|
+
const dedupReporter = makeHttpReporter();
|
|
1056
|
+
await reportCronFailure("slack-fail-channel", thrown, {
|
|
1057
|
+
cronRunReporter: dedupReporter,
|
|
1058
|
+
clock: FixedClock(FIXED_TIME),
|
|
1059
|
+
});
|
|
1060
|
+
failureReportCalls.push({ requests: reporterState.requests.length });
|
|
1061
|
+
|
|
1062
|
+
// No additional createRun/completeRun requests were made by
|
|
1063
|
+
// reportCronFailure — still just the original CREATE + PATCH pair.
|
|
1064
|
+
expect(reporterState.requests).toHaveLength(2);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test("DM post-completion Slack failure ('could not open DM'): run recorded 'completed', thrown error tagged so reportCronFailure skips a duplicate 'failed' row", async () => {
|
|
1068
|
+
mockRunner.mockResolvedValueOnce({
|
|
1069
|
+
result: "dm reply",
|
|
1070
|
+
sessionId: "s1",
|
|
1071
|
+
usage: {
|
|
1072
|
+
input_tokens: 10,
|
|
1073
|
+
output_tokens: 5,
|
|
1074
|
+
cache_read_input_tokens: 0,
|
|
1075
|
+
cache_creation_input_tokens: 0,
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
// Pre-run resolution (STC-1.3) also calls conversations.open before the
|
|
1079
|
+
// run — queue a null response for both that pre-run attempt and the
|
|
1080
|
+
// post-run fallback so DM delivery still fails end-to-end.
|
|
1081
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally null channel
|
|
1082
|
+
mockConversationsOpen.mockResolvedValueOnce({ channel: null } as any);
|
|
1083
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock — intentionally null channel
|
|
1084
|
+
mockConversationsOpen.mockResolvedValueOnce({ channel: null } as any);
|
|
1085
|
+
|
|
1086
|
+
const reporter = makeHttpReporter();
|
|
1087
|
+
const thrown = await handleCronRequest(
|
|
1088
|
+
{ jobId: "slack-fail-dm", prompt: "hello", user: "U-DAN" },
|
|
1089
|
+
{ ...deps, cronRunReporter: reporter, clock: FixedClock(FIXED_TIME) },
|
|
1090
|
+
).then(
|
|
1091
|
+
() => undefined,
|
|
1092
|
+
(err) => err,
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
1096
|
+
expect((thrown as Error).message).toContain("could not open DM");
|
|
1097
|
+
|
|
1098
|
+
// The run was already recorded as "completed" before the DM-open failure.
|
|
1099
|
+
const patchReq = reporterState.requests.find((r) => r.method === "PATCH");
|
|
1100
|
+
expect(patchReq).toBeDefined();
|
|
1101
|
+
const patchBody = patchReq?.body as Record<string, unknown>;
|
|
1102
|
+
expect(patchBody.outcome).toBe("completed");
|
|
1103
|
+
expect(
|
|
1104
|
+
reporterState.requests.filter((r) => r.method === "PATCH"),
|
|
1105
|
+
).toHaveLength(1);
|
|
1106
|
+
|
|
1107
|
+
const dedupReporter = makeHttpReporter();
|
|
1108
|
+
await reportCronFailure("slack-fail-dm", thrown, {
|
|
1109
|
+
cronRunReporter: dedupReporter,
|
|
1110
|
+
clock: FixedClock(FIXED_TIME),
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// reportCronFailure must not have made any additional requests against
|
|
1114
|
+
// the reporter stub — the tag caused it to skip createRun/completeRun.
|
|
1115
|
+
expect(reporterState.requests).toHaveLength(2);
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// ─── POST /cron HTTP endpoint ─────────────────────────────────────────────────
|
|
1120
|
+
|
|
1121
|
+
describe("POST /cron HTTP endpoint", () => {
|
|
1122
|
+
// biome-ignore lint/suspicious/noExplicitAny: Server type param varies by bun version
|
|
1123
|
+
const servers: Server<any>[] = [];
|
|
1124
|
+
|
|
1125
|
+
function serve(
|
|
1126
|
+
sentryClient?: ErrorCapturingClient,
|
|
1127
|
+
// biome-ignore lint/suspicious/noExplicitAny: Server type param varies by bun version
|
|
1128
|
+
): Server<any> {
|
|
1129
|
+
const s = startHealthServer(0, deps, undefined, undefined, sentryClient);
|
|
1130
|
+
servers.push(s);
|
|
1131
|
+
return s;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
afterEach(async () => {
|
|
1135
|
+
for (const s of servers) await s.stop(true);
|
|
1136
|
+
servers.length = 0;
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test("200 on valid channel request", async () => {
|
|
1140
|
+
mockRunner.mockResolvedValueOnce({ result: "ok", sessionId: "s" });
|
|
1141
|
+
const server = serve();
|
|
1142
|
+
|
|
1143
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1144
|
+
method: "POST",
|
|
1145
|
+
headers: { "Content-Type": "application/json" },
|
|
1146
|
+
body: JSON.stringify({ jobId: "j1", prompt: "hello", channel: "C-X" }),
|
|
1147
|
+
});
|
|
1148
|
+
expect(res.status).toBe(200);
|
|
1149
|
+
const body = await res.json();
|
|
1150
|
+
expect(body).toEqual({ ok: true });
|
|
1151
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
test("200 on valid user DM request", async () => {
|
|
1155
|
+
mockRunner.mockResolvedValueOnce({ result: "dm reply", sessionId: "s" });
|
|
1156
|
+
const server = serve();
|
|
1157
|
+
|
|
1158
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1159
|
+
method: "POST",
|
|
1160
|
+
headers: { "Content-Type": "application/json" },
|
|
1161
|
+
body: JSON.stringify({ jobId: "j2", prompt: "daily", user: "U-DAN" }),
|
|
1162
|
+
});
|
|
1163
|
+
expect(res.status).toBe(200);
|
|
1164
|
+
expect(mockConversationsOpen).toHaveBeenCalledTimes(1);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test("400 for missing prompt", async () => {
|
|
1168
|
+
const server = serve();
|
|
1169
|
+
|
|
1170
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1171
|
+
method: "POST",
|
|
1172
|
+
headers: { "Content-Type": "application/json" },
|
|
1173
|
+
body: JSON.stringify({ jobId: "j3" }),
|
|
1174
|
+
});
|
|
1175
|
+
expect(res.status).toBe(400);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
test("422 when neither channel nor user and not silent", async () => {
|
|
1179
|
+
const server = serve();
|
|
1180
|
+
|
|
1181
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1182
|
+
method: "POST",
|
|
1183
|
+
headers: { "Content-Type": "application/json" },
|
|
1184
|
+
body: JSON.stringify({ jobId: "j4", prompt: "hello" }),
|
|
1185
|
+
});
|
|
1186
|
+
expect(res.status).toBe(422);
|
|
1187
|
+
const body = await res.json();
|
|
1188
|
+
expect(body.error).toContain("delivery target");
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test("400 for invalid JSON body", async () => {
|
|
1192
|
+
const server = serve();
|
|
1193
|
+
|
|
1194
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1195
|
+
method: "POST",
|
|
1196
|
+
headers: { "Content-Type": "application/json" },
|
|
1197
|
+
body: "not json {{",
|
|
1198
|
+
});
|
|
1199
|
+
expect(res.status).toBe(400);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
test("400 for missing jobId in body", async () => {
|
|
1203
|
+
const server = serve();
|
|
1204
|
+
|
|
1205
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1206
|
+
method: "POST",
|
|
1207
|
+
headers: { "Content-Type": "application/json" },
|
|
1208
|
+
body: JSON.stringify({ prompt: "hello", channel: "C-X" }),
|
|
1209
|
+
});
|
|
1210
|
+
expect(res.status).toBe(400);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
test("500 when Claude runner throws", async () => {
|
|
1214
|
+
mockRunner.mockRejectedValueOnce(new Error("claude down"));
|
|
1215
|
+
const server = serve();
|
|
1216
|
+
|
|
1217
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1218
|
+
method: "POST",
|
|
1219
|
+
headers: { "Content-Type": "application/json" },
|
|
1220
|
+
body: JSON.stringify({ jobId: "j5", prompt: "run", channel: "C-X" }),
|
|
1221
|
+
});
|
|
1222
|
+
expect(res.status).toBe(500);
|
|
1223
|
+
const body = await res.json();
|
|
1224
|
+
expect(body.error).toContain("claude down");
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test("500 with sentryClient injected — captureException called with the error, response unchanged", async () => {
|
|
1228
|
+
mockRunner.mockRejectedValueOnce(new Error("claude down"));
|
|
1229
|
+
const capturedErrors: unknown[] = [];
|
|
1230
|
+
const fakeSentryClient: ErrorCapturingClient = {
|
|
1231
|
+
captureException: (err: unknown) => {
|
|
1232
|
+
capturedErrors.push(err);
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
const server = serve(fakeSentryClient);
|
|
1236
|
+
|
|
1237
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1238
|
+
method: "POST",
|
|
1239
|
+
headers: { "Content-Type": "application/json" },
|
|
1240
|
+
body: JSON.stringify({ jobId: "j8", prompt: "run", channel: "C-X" }),
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
expect(res.status).toBe(500);
|
|
1244
|
+
const body = await res.json();
|
|
1245
|
+
expect(body.error).toContain("claude down");
|
|
1246
|
+
expect(capturedErrors.length).toBe(1);
|
|
1247
|
+
expect((capturedErrors[0] as Error).message).toBe("claude down");
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
test("SENTRY-46: a ceiling-reason ClaudeTimeoutError reports via captureMessage, not captureException", async () => {
|
|
1251
|
+
mockRunner.mockRejectedValueOnce(
|
|
1252
|
+
new ClaudeTimeoutError(3_600_000, "ceiling"),
|
|
1253
|
+
);
|
|
1254
|
+
const capturedErrors: unknown[] = [];
|
|
1255
|
+
const capturedMessages: string[] = [];
|
|
1256
|
+
const fakeSentryClient: ErrorCapturingClient = {
|
|
1257
|
+
captureException: (err: unknown) => {
|
|
1258
|
+
capturedErrors.push(err);
|
|
1259
|
+
},
|
|
1260
|
+
captureMessage: (message: string) => {
|
|
1261
|
+
capturedMessages.push(message);
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
const server = serve(fakeSentryClient);
|
|
1265
|
+
|
|
1266
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1267
|
+
method: "POST",
|
|
1268
|
+
headers: { "Content-Type": "application/json" },
|
|
1269
|
+
body: JSON.stringify({ jobId: "j9", prompt: "run", channel: "C-X" }),
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
expect(res.status).toBe(500);
|
|
1273
|
+
expect(capturedErrors.length).toBe(0);
|
|
1274
|
+
expect(capturedMessages.length).toBe(1);
|
|
1275
|
+
expect(capturedMessages[0]).toContain("3600s");
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("an idle-reason ClaudeTimeoutError still reports via captureException", async () => {
|
|
1279
|
+
mockRunner.mockRejectedValueOnce(new ClaudeTimeoutError(1_500_000, "idle"));
|
|
1280
|
+
const capturedErrors: unknown[] = [];
|
|
1281
|
+
const capturedMessages: string[] = [];
|
|
1282
|
+
const fakeSentryClient: ErrorCapturingClient = {
|
|
1283
|
+
captureException: (err: unknown) => {
|
|
1284
|
+
capturedErrors.push(err);
|
|
1285
|
+
},
|
|
1286
|
+
captureMessage: (message: string) => {
|
|
1287
|
+
capturedMessages.push(message);
|
|
1288
|
+
},
|
|
1289
|
+
};
|
|
1290
|
+
const server = serve(fakeSentryClient);
|
|
1291
|
+
|
|
1292
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1293
|
+
method: "POST",
|
|
1294
|
+
headers: { "Content-Type": "application/json" },
|
|
1295
|
+
body: JSON.stringify({ jobId: "j10", prompt: "run", channel: "C-X" }),
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
expect(res.status).toBe(500);
|
|
1299
|
+
expect(capturedMessages.length).toBe(0);
|
|
1300
|
+
expect(capturedErrors.length).toBe(1);
|
|
1301
|
+
expect((capturedErrors[0] as ClaudeTimeoutError).reason).toBe("idle");
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
test("422 ValidationError does NOT call sentryClient.captureException", async () => {
|
|
1305
|
+
const capturedErrors: unknown[] = [];
|
|
1306
|
+
const fakeSentryClient: ErrorCapturingClient = {
|
|
1307
|
+
captureException: (err: unknown) => {
|
|
1308
|
+
capturedErrors.push(err);
|
|
1309
|
+
},
|
|
1310
|
+
};
|
|
1311
|
+
const server = serve(fakeSentryClient);
|
|
1312
|
+
|
|
1313
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1314
|
+
method: "POST",
|
|
1315
|
+
headers: { "Content-Type": "application/json" },
|
|
1316
|
+
body: JSON.stringify({ jobId: "j9", prompt: "hello" }),
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
expect(res.status).toBe(422);
|
|
1320
|
+
expect(capturedErrors.length).toBe(0);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test("500 with no sentryClient wired — behaves exactly as before (no throw, same response)", async () => {
|
|
1324
|
+
mockRunner.mockRejectedValueOnce(new Error("claude down"));
|
|
1325
|
+
const server = serve();
|
|
1326
|
+
|
|
1327
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1328
|
+
method: "POST",
|
|
1329
|
+
headers: { "Content-Type": "application/json" },
|
|
1330
|
+
body: JSON.stringify({ jobId: "j10", prompt: "run", channel: "C-X" }),
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
expect(res.status).toBe(500);
|
|
1334
|
+
const body = await res.json();
|
|
1335
|
+
expect(body.error).toContain("claude down");
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("200 with silent=true — no Slack post", async () => {
|
|
1339
|
+
mockRunner.mockResolvedValueOnce({ result: "done", sessionId: "s" });
|
|
1340
|
+
const server = serve();
|
|
1341
|
+
|
|
1342
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1343
|
+
method: "POST",
|
|
1344
|
+
headers: { "Content-Type": "application/json" },
|
|
1345
|
+
body: JSON.stringify({ jobId: "j6", prompt: "run", silent: true }),
|
|
1346
|
+
});
|
|
1347
|
+
expect(res.status).toBe(200);
|
|
1348
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
test("503 when cronDeps not configured", async () => {
|
|
1352
|
+
const s = startHealthServer(0);
|
|
1353
|
+
servers.push(s);
|
|
1354
|
+
|
|
1355
|
+
const res = await fetch(`http://localhost:${s.port}/cron`, {
|
|
1356
|
+
method: "POST",
|
|
1357
|
+
headers: { "Content-Type": "application/json" },
|
|
1358
|
+
body: JSON.stringify({ jobId: "j7", prompt: "run", channel: "C-X" }),
|
|
1359
|
+
});
|
|
1360
|
+
expect(res.status).toBe(503);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("405 on GET /cron", async () => {
|
|
1364
|
+
const server = serve();
|
|
1365
|
+
|
|
1366
|
+
const res = await fetch(`http://localhost:${server.port}/cron`, {
|
|
1367
|
+
method: "GET",
|
|
1368
|
+
});
|
|
1369
|
+
expect(res.status).toBe(405);
|
|
1370
|
+
});
|
|
1371
|
+
});
|