@app-vitals/agent-runtime 0.0.0-stage → 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 +40 -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,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for cron-handler.ts's progress push + partial-usage-on-
|
|
3
|
+
* failure wiring (CSU-3.2 — the generic cron-dispatch-path port of CSU-3.1's
|
|
4
|
+
* loop-orchestrator.ts wiring).
|
|
5
|
+
*
|
|
6
|
+
* Strategy: inject all deps (Clock, CronRunReporter, runner) — no
|
|
7
|
+
* mock.module(), no real Slack/Claude calls. Mirrors
|
|
8
|
+
* loop-orchestrator.integration.test.ts's CSU-3.1 suite.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import type { WebClient } from "@slack/web-api";
|
|
13
|
+
import {
|
|
14
|
+
ClaudeRunError,
|
|
15
|
+
type ClaudeRunResult,
|
|
16
|
+
ClaudeTimeoutError,
|
|
17
|
+
type ModelUsage,
|
|
18
|
+
type ProgressCallback,
|
|
19
|
+
} from "./claude.ts";
|
|
20
|
+
import type { Clock } from "./clock.ts";
|
|
21
|
+
import { handleCronRequest } from "./cron-handler.ts";
|
|
22
|
+
import type {
|
|
23
|
+
CronRunReporter,
|
|
24
|
+
ModelBreakdownEntry,
|
|
25
|
+
} from "./cron-run-reporter.ts";
|
|
26
|
+
|
|
27
|
+
// ─── Shared fakes ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const mockSlack = {
|
|
30
|
+
chat: { postMessage: async () => ({ ok: true, ts: "1234567890.000001" }) },
|
|
31
|
+
conversations: { open: async () => ({ channel: { id: "D_DM" } }) },
|
|
32
|
+
} as unknown as WebClient;
|
|
33
|
+
|
|
34
|
+
function makeUsage(overrides: Partial<ModelBreakdownEntry> = {}): ModelUsage {
|
|
35
|
+
return {
|
|
36
|
+
"claude-opus-4": {
|
|
37
|
+
inputTokens: 100,
|
|
38
|
+
outputTokens: 50,
|
|
39
|
+
cacheReadInputTokens: 0,
|
|
40
|
+
cacheCreationInputTokens: 0,
|
|
41
|
+
costUSD: 0.42,
|
|
42
|
+
...overrides,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A mutable clock — lets debounce tests advance "now" between calls. */
|
|
48
|
+
function makeMutableClock(start: Date): Clock & { advance(ms: number): void } {
|
|
49
|
+
let current = start;
|
|
50
|
+
return {
|
|
51
|
+
now: () => current,
|
|
52
|
+
advance(ms: number) {
|
|
53
|
+
current = new Date(current.getTime() + ms);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeRecordingReporter(): {
|
|
59
|
+
reporter: CronRunReporter;
|
|
60
|
+
completeCalls: Array<{
|
|
61
|
+
outcome: "completed" | "failed";
|
|
62
|
+
opts?: {
|
|
63
|
+
error?: string;
|
|
64
|
+
modelBreakdown?: ModelBreakdownEntry[];
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
progressCalls: Array<{
|
|
69
|
+
runId: string | null;
|
|
70
|
+
modelBreakdown: ModelBreakdownEntry[];
|
|
71
|
+
lastHeartbeatAt: Date;
|
|
72
|
+
}>;
|
|
73
|
+
} {
|
|
74
|
+
const completeCalls: Array<{
|
|
75
|
+
outcome: "completed" | "failed";
|
|
76
|
+
opts?: {
|
|
77
|
+
error?: string;
|
|
78
|
+
modelBreakdown?: ModelBreakdownEntry[];
|
|
79
|
+
sessionId?: string;
|
|
80
|
+
};
|
|
81
|
+
}> = [];
|
|
82
|
+
const progressCalls: Array<{
|
|
83
|
+
runId: string | null;
|
|
84
|
+
modelBreakdown: ModelBreakdownEntry[];
|
|
85
|
+
lastHeartbeatAt: Date;
|
|
86
|
+
}> = [];
|
|
87
|
+
const reporter: CronRunReporter = {
|
|
88
|
+
async createRun() {
|
|
89
|
+
return "run-1";
|
|
90
|
+
},
|
|
91
|
+
async completeRun(_cronId, _runId, _completedAt, outcome, opts) {
|
|
92
|
+
completeCalls.push({ outcome, opts });
|
|
93
|
+
},
|
|
94
|
+
async skipRun() {},
|
|
95
|
+
async recordProgress(_cronId, runId, modelBreakdown, lastHeartbeatAt) {
|
|
96
|
+
progressCalls.push({ runId, modelBreakdown, lastHeartbeatAt });
|
|
97
|
+
},
|
|
98
|
+
async recordSessionId() {},
|
|
99
|
+
};
|
|
100
|
+
return { reporter, completeCalls, progressCalls };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Progress push + partial-usage-on-failure (CSU-3.2) ─────────────────────
|
|
104
|
+
|
|
105
|
+
describe("handleCronRequest + progress push / partial-usage-on-failure (CSU-3.2)", () => {
|
|
106
|
+
test("a runner that emits progress via onProgress before resolving triggers recordProgress mid-dispatch, before completeRun", async () => {
|
|
107
|
+
const { reporter, completeCalls, progressCalls } = makeRecordingReporter();
|
|
108
|
+
const callOrder: string[] = [];
|
|
109
|
+
|
|
110
|
+
const trackedReporter: CronRunReporter = {
|
|
111
|
+
...reporter,
|
|
112
|
+
async recordProgress(cronId, runId, modelBreakdown, lastHeartbeatAt) {
|
|
113
|
+
callOrder.push("recordProgress");
|
|
114
|
+
await reporter.recordProgress(
|
|
115
|
+
cronId,
|
|
116
|
+
runId,
|
|
117
|
+
modelBreakdown,
|
|
118
|
+
lastHeartbeatAt,
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
async completeRun(
|
|
122
|
+
cronId,
|
|
123
|
+
runId,
|
|
124
|
+
completedAt,
|
|
125
|
+
outcome,
|
|
126
|
+
opts,
|
|
127
|
+
phaseId,
|
|
128
|
+
itemType,
|
|
129
|
+
itemId,
|
|
130
|
+
) {
|
|
131
|
+
callOrder.push("completeRun");
|
|
132
|
+
await reporter.completeRun(
|
|
133
|
+
cronId,
|
|
134
|
+
runId,
|
|
135
|
+
completedAt,
|
|
136
|
+
outcome,
|
|
137
|
+
opts,
|
|
138
|
+
phaseId,
|
|
139
|
+
itemType,
|
|
140
|
+
itemId,
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const runner = async (
|
|
146
|
+
_message: string,
|
|
147
|
+
onProgress?: ProgressCallback,
|
|
148
|
+
): Promise<ClaudeRunResult> => {
|
|
149
|
+
onProgress?.(makeUsage());
|
|
150
|
+
return { result: "done", sessionId: "s1", modelUsage: makeUsage() };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
await handleCronRequest(
|
|
154
|
+
{ jobId: "progress-job", prompt: "hello", channel: "C-X" },
|
|
155
|
+
{
|
|
156
|
+
slack: mockSlack,
|
|
157
|
+
runner,
|
|
158
|
+
cronRunReporter: trackedReporter,
|
|
159
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(progressCalls).toHaveLength(1);
|
|
164
|
+
expect(progressCalls[0]?.modelBreakdown).toEqual([
|
|
165
|
+
{
|
|
166
|
+
model: "claude-opus-4",
|
|
167
|
+
inputTokens: 100,
|
|
168
|
+
outputTokens: 50,
|
|
169
|
+
cacheReadTokens: 0,
|
|
170
|
+
cacheCreationTokens: 0,
|
|
171
|
+
costUsd: 0.42,
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
expect(completeCalls).toHaveLength(1);
|
|
175
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
176
|
+
|
|
177
|
+
// recordProgress fired before completeRun for this dispatch.
|
|
178
|
+
expect(callOrder).toEqual(["recordProgress", "completeRun"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a thrown ClaudeTimeoutError with partial usage results in completeRun's failed call carrying modelBreakdown", async () => {
|
|
182
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
183
|
+
|
|
184
|
+
const partialUsage = makeUsage({ inputTokens: 10, outputTokens: 5 });
|
|
185
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
186
|
+
throw new ClaudeTimeoutError(600_000, "ceiling", partialUsage);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
handleCronRequest(
|
|
191
|
+
{ jobId: "timeout-job", prompt: "hello", channel: "C-X" },
|
|
192
|
+
{
|
|
193
|
+
slack: mockSlack,
|
|
194
|
+
runner,
|
|
195
|
+
cronRunReporter: reporter,
|
|
196
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
).rejects.toThrow(ClaudeTimeoutError);
|
|
200
|
+
|
|
201
|
+
expect(completeCalls).toHaveLength(1);
|
|
202
|
+
expect(completeCalls[0]?.outcome).toBe("failed");
|
|
203
|
+
expect(completeCalls[0]?.opts?.error).toContain("timed out");
|
|
204
|
+
expect(completeCalls[0]?.opts?.modelBreakdown).toEqual([
|
|
205
|
+
{
|
|
206
|
+
model: "claude-opus-4",
|
|
207
|
+
inputTokens: 10,
|
|
208
|
+
outputTokens: 5,
|
|
209
|
+
cacheReadTokens: 0,
|
|
210
|
+
cacheCreationTokens: 0,
|
|
211
|
+
costUsd: 0.42,
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("two rapid onProgress calls (<5s apart) only trigger one recordProgress call (debounced)", async () => {
|
|
217
|
+
const { reporter, progressCalls } = makeRecordingReporter();
|
|
218
|
+
const clock = makeMutableClock(new Date("2026-07-20T00:00:00Z"));
|
|
219
|
+
|
|
220
|
+
const runner = async (
|
|
221
|
+
_message: string,
|
|
222
|
+
onProgress?: ProgressCallback,
|
|
223
|
+
): Promise<ClaudeRunResult> => {
|
|
224
|
+
onProgress?.(makeUsage({ inputTokens: 10 }));
|
|
225
|
+
// Advance less than the 5s debounce window before the second push.
|
|
226
|
+
clock.advance(1000);
|
|
227
|
+
onProgress?.(makeUsage({ inputTokens: 20 }));
|
|
228
|
+
return {
|
|
229
|
+
result: "done",
|
|
230
|
+
sessionId: "s1",
|
|
231
|
+
modelUsage: makeUsage({ inputTokens: 20 }),
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await handleCronRequest(
|
|
236
|
+
{ jobId: "debounce-job", prompt: "hello", channel: "C-X" },
|
|
237
|
+
{
|
|
238
|
+
slack: mockSlack,
|
|
239
|
+
runner,
|
|
240
|
+
cronRunReporter: reporter,
|
|
241
|
+
clock,
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(progressCalls).toHaveLength(1);
|
|
246
|
+
expect(progressCalls[0]?.modelBreakdown?.[0]?.inputTokens).toBe(10);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ─── lastHeartbeatAt sourced from the injected Clock (CRH-1.1) ────────────
|
|
250
|
+
|
|
251
|
+
test("recordProgress's lastHeartbeatAt is sourced from the injected Clock at push time, not wall-clock", async () => {
|
|
252
|
+
const { reporter, progressCalls } = makeRecordingReporter();
|
|
253
|
+
// A clock fixed far from the real wall clock — if the implementation ever
|
|
254
|
+
// reached for new Date()/Date.now() instead of clock.now(), the pushed
|
|
255
|
+
// timestamp would not equal this fixed instant.
|
|
256
|
+
const clock = makeMutableClock(new Date("2020-01-01T00:00:00Z"));
|
|
257
|
+
|
|
258
|
+
const runner = async (
|
|
259
|
+
_message: string,
|
|
260
|
+
onProgress?: ProgressCallback,
|
|
261
|
+
): Promise<ClaudeRunResult> => {
|
|
262
|
+
onProgress?.(makeUsage());
|
|
263
|
+
return { result: "done", sessionId: "s1", modelUsage: makeUsage() };
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
await handleCronRequest(
|
|
267
|
+
{ jobId: "heartbeat-job", prompt: "hello", channel: "C-X" },
|
|
268
|
+
{
|
|
269
|
+
slack: mockSlack,
|
|
270
|
+
runner,
|
|
271
|
+
cronRunReporter: reporter,
|
|
272
|
+
clock,
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(progressCalls).toHaveLength(1);
|
|
277
|
+
expect(progressCalls[0]?.lastHeartbeatAt).toEqual(
|
|
278
|
+
new Date("2020-01-01T00:00:00Z"),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("two debounced progress pushes at different clock ticks each carry their own lastHeartbeatAt", async () => {
|
|
283
|
+
const { reporter, progressCalls } = makeRecordingReporter();
|
|
284
|
+
const clock = makeMutableClock(new Date("2026-07-20T00:00:00Z"));
|
|
285
|
+
|
|
286
|
+
const runner = async (
|
|
287
|
+
_message: string,
|
|
288
|
+
onProgress?: ProgressCallback,
|
|
289
|
+
): Promise<ClaudeRunResult> => {
|
|
290
|
+
onProgress?.(makeUsage({ inputTokens: 10 }));
|
|
291
|
+
// Advance past the 5s debounce window before the second push.
|
|
292
|
+
clock.advance(6000);
|
|
293
|
+
onProgress?.(makeUsage({ inputTokens: 20 }));
|
|
294
|
+
return {
|
|
295
|
+
result: "done",
|
|
296
|
+
sessionId: "s1",
|
|
297
|
+
modelUsage: makeUsage({ inputTokens: 20 }),
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
await handleCronRequest(
|
|
302
|
+
{ jobId: "heartbeat-debounce-job", prompt: "hello", channel: "C-X" },
|
|
303
|
+
{
|
|
304
|
+
slack: mockSlack,
|
|
305
|
+
runner,
|
|
306
|
+
cronRunReporter: reporter,
|
|
307
|
+
clock,
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(progressCalls).toHaveLength(2);
|
|
312
|
+
expect(progressCalls[0]?.lastHeartbeatAt).toEqual(
|
|
313
|
+
new Date("2026-07-20T00:00:00Z"),
|
|
314
|
+
);
|
|
315
|
+
expect(progressCalls[1]?.lastHeartbeatAt).toEqual(
|
|
316
|
+
new Date("2026-07-20T00:00:06Z"),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ─── sessionId wired into completeRun (CSI-2.2) ─────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe("handleCronRequest + sessionId wiring into completeRun (CSI-2.2)", () => {
|
|
324
|
+
test("a completed non-silent channel-post run's completeRun opts include the runResult's sessionId", async () => {
|
|
325
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
326
|
+
|
|
327
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
328
|
+
return {
|
|
329
|
+
result: "done",
|
|
330
|
+
sessionId: "session-channel-1",
|
|
331
|
+
modelUsage: makeUsage(),
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
await handleCronRequest(
|
|
336
|
+
{ jobId: "channel-job", prompt: "hello", channel: "C-X" },
|
|
337
|
+
{
|
|
338
|
+
slack: mockSlack,
|
|
339
|
+
runner,
|
|
340
|
+
cronRunReporter: reporter,
|
|
341
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(completeCalls).toHaveLength(1);
|
|
346
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
347
|
+
expect(completeCalls[0]?.opts?.sessionId).toBe("session-channel-1");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a completed DM-post run's completeRun opts include the runResult's sessionId", async () => {
|
|
351
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
352
|
+
|
|
353
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
354
|
+
return {
|
|
355
|
+
result: "done",
|
|
356
|
+
sessionId: "session-dm-1",
|
|
357
|
+
modelUsage: makeUsage(),
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
await handleCronRequest(
|
|
362
|
+
{ jobId: "dm-job", prompt: "hello", user: "U-X" },
|
|
363
|
+
{
|
|
364
|
+
slack: mockSlack,
|
|
365
|
+
runner,
|
|
366
|
+
cronRunReporter: reporter,
|
|
367
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(completeCalls).toHaveLength(1);
|
|
372
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
373
|
+
expect(completeCalls[0]?.opts?.sessionId).toBe("session-dm-1");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("a silent (req.silent) run's completeRun opts include the runResult's sessionId", async () => {
|
|
377
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
378
|
+
|
|
379
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
380
|
+
return {
|
|
381
|
+
result: "done",
|
|
382
|
+
sessionId: "session-silent-1",
|
|
383
|
+
modelUsage: makeUsage(),
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
await handleCronRequest(
|
|
388
|
+
{ jobId: "silent-job", prompt: "hello", silent: true },
|
|
389
|
+
{
|
|
390
|
+
slack: mockSlack,
|
|
391
|
+
runner,
|
|
392
|
+
cronRunReporter: reporter,
|
|
393
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
expect(completeCalls).toHaveLength(1);
|
|
398
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
399
|
+
expect(completeCalls[0]?.opts?.sessionId).toBe("session-silent-1");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("a run whose result carries a [silent] marker (isSilentMarker && !isDmOnly) has completeRun opts include the runResult's sessionId", async () => {
|
|
403
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
404
|
+
|
|
405
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
406
|
+
return {
|
|
407
|
+
result: "done [silent]",
|
|
408
|
+
sessionId: "session-silent-marker-1",
|
|
409
|
+
modelUsage: makeUsage(),
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
await handleCronRequest(
|
|
414
|
+
{ jobId: "silent-marker-job", prompt: "hello", channel: "C-X" },
|
|
415
|
+
{
|
|
416
|
+
slack: mockSlack,
|
|
417
|
+
runner,
|
|
418
|
+
cronRunReporter: reporter,
|
|
419
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
expect(completeCalls).toHaveLength(1);
|
|
424
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
425
|
+
expect(completeCalls[0]?.opts?.sessionId).toBe("session-silent-marker-1");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("a thrown ClaudeTimeoutError carrying a sessionId results in completeRun's failed call carrying that sessionId", async () => {
|
|
429
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
430
|
+
|
|
431
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
432
|
+
throw new ClaudeTimeoutError(
|
|
433
|
+
600_000,
|
|
434
|
+
"ceiling",
|
|
435
|
+
makeUsage(),
|
|
436
|
+
"session-timeout-1",
|
|
437
|
+
);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
await expect(
|
|
441
|
+
handleCronRequest(
|
|
442
|
+
{ jobId: "timeout-job", prompt: "hello", channel: "C-X" },
|
|
443
|
+
{
|
|
444
|
+
slack: mockSlack,
|
|
445
|
+
runner,
|
|
446
|
+
cronRunReporter: reporter,
|
|
447
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
448
|
+
},
|
|
449
|
+
),
|
|
450
|
+
).rejects.toThrow(ClaudeTimeoutError);
|
|
451
|
+
|
|
452
|
+
expect(completeCalls).toHaveLength(1);
|
|
453
|
+
expect(completeCalls[0]?.outcome).toBe("failed");
|
|
454
|
+
expect(completeCalls[0]?.opts?.sessionId).toBe("session-timeout-1");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("a thrown ClaudeRunError carrying a sessionId results in completeRun's failed call carrying that sessionId", async () => {
|
|
458
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
459
|
+
|
|
460
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
461
|
+
throw new ClaudeRunError(
|
|
462
|
+
"boom",
|
|
463
|
+
undefined,
|
|
464
|
+
"resultMessage detail",
|
|
465
|
+
"session-run-error-1",
|
|
466
|
+
);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
await expect(
|
|
470
|
+
handleCronRequest(
|
|
471
|
+
{ jobId: "run-error-job", prompt: "hello", channel: "C-X" },
|
|
472
|
+
{
|
|
473
|
+
slack: mockSlack,
|
|
474
|
+
runner,
|
|
475
|
+
cronRunReporter: reporter,
|
|
476
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
477
|
+
},
|
|
478
|
+
),
|
|
479
|
+
).rejects.toThrow(ClaudeRunError);
|
|
480
|
+
|
|
481
|
+
expect(completeCalls).toHaveLength(1);
|
|
482
|
+
expect(completeCalls[0]?.outcome).toBe("failed");
|
|
483
|
+
expect(completeCalls[0]?.opts?.sessionId).toBe("session-run-error-1");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("a thrown plain Error (no sessionId field) results in completeRun's failed call with sessionId undefined", async () => {
|
|
487
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
488
|
+
|
|
489
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
490
|
+
throw new Error("generic failure");
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
await expect(
|
|
494
|
+
handleCronRequest(
|
|
495
|
+
{ jobId: "generic-error-job", prompt: "hello", channel: "C-X" },
|
|
496
|
+
{
|
|
497
|
+
slack: mockSlack,
|
|
498
|
+
runner,
|
|
499
|
+
cronRunReporter: reporter,
|
|
500
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
501
|
+
},
|
|
502
|
+
),
|
|
503
|
+
).rejects.toThrow("generic failure");
|
|
504
|
+
|
|
505
|
+
expect(completeCalls).toHaveLength(1);
|
|
506
|
+
expect(completeCalls[0]?.outcome).toBe("failed");
|
|
507
|
+
expect(completeCalls[0]?.opts?.sessionId).toBeUndefined();
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// ─── Chat-only agent (no Slack client) ────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
describe("cron dispatch on an agent with no Slack credentials", () => {
|
|
514
|
+
/**
|
|
515
|
+
* A chat-only agent gets `slack: undefined` from index.ts rather than a
|
|
516
|
+
* WebClient wrapped around an empty token. The job must still run and be
|
|
517
|
+
* recorded as completed — only the delivery step is skipped.
|
|
518
|
+
*/
|
|
519
|
+
test("a channel-targeted job completes and posts nothing", async () => {
|
|
520
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
521
|
+
let posted = false;
|
|
522
|
+
const runner = async (): Promise<ClaudeRunResult> => {
|
|
523
|
+
posted = false;
|
|
524
|
+
return { result: "work happened", sessionId: "s1" };
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
await handleCronRequest(
|
|
528
|
+
{ jobId: "chat-only-channel", prompt: "hello", channel: "C-X" },
|
|
529
|
+
{
|
|
530
|
+
slack: undefined,
|
|
531
|
+
runner,
|
|
532
|
+
cronRunReporter: reporter,
|
|
533
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
534
|
+
},
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
expect(completeCalls).toHaveLength(1);
|
|
538
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
539
|
+
expect(posted).toBe(false);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("a DM-targeted job completes and opens no conversation", async () => {
|
|
543
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
544
|
+
const runner = async (): Promise<ClaudeRunResult> => ({
|
|
545
|
+
result: "work happened",
|
|
546
|
+
sessionId: "s1",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await handleCronRequest(
|
|
550
|
+
{ jobId: "chat-only-dm", prompt: "hello", user: "U-X" },
|
|
551
|
+
{
|
|
552
|
+
slack: undefined,
|
|
553
|
+
runner,
|
|
554
|
+
cronRunReporter: reporter,
|
|
555
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
expect(completeCalls).toHaveLength(1);
|
|
560
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("a silent job is unaffected — it never reached delivery anyway", async () => {
|
|
564
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
565
|
+
const runner = async (): Promise<ClaudeRunResult> => ({
|
|
566
|
+
result: "quiet work",
|
|
567
|
+
sessionId: "s1",
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
await handleCronRequest(
|
|
571
|
+
{ jobId: "chat-only-silent", prompt: "hello", silent: true },
|
|
572
|
+
{
|
|
573
|
+
slack: undefined,
|
|
574
|
+
runner,
|
|
575
|
+
cronRunReporter: reporter,
|
|
576
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
expect(completeCalls).toHaveLength(1);
|
|
581
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("with a Slack client present, a channel job still delivers", async () => {
|
|
585
|
+
const { reporter, completeCalls } = makeRecordingReporter();
|
|
586
|
+
const channels: string[] = [];
|
|
587
|
+
const recordingSlack = {
|
|
588
|
+
chat: {
|
|
589
|
+
postMessage: async (args: { channel: string }) => {
|
|
590
|
+
channels.push(args.channel);
|
|
591
|
+
return { ok: true, ts: "1234567890.000001" };
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
conversations: { open: async () => ({ channel: { id: "D_DM" } }) },
|
|
595
|
+
} as unknown as WebClient;
|
|
596
|
+
const runner = async (): Promise<ClaudeRunResult> => ({
|
|
597
|
+
result: "work happened",
|
|
598
|
+
sessionId: "s1",
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await handleCronRequest(
|
|
602
|
+
{ jobId: "slack-present", prompt: "hello", channel: "C-X" },
|
|
603
|
+
{
|
|
604
|
+
slack: recordingSlack,
|
|
605
|
+
runner,
|
|
606
|
+
cronRunReporter: reporter,
|
|
607
|
+
clock: makeMutableClock(new Date("2026-07-20T00:00:00Z")),
|
|
608
|
+
},
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
expect(channels).toEqual(["C-X"]);
|
|
612
|
+
expect(completeCalls[0]?.outcome).toBe("completed");
|
|
613
|
+
});
|
|
614
|
+
});
|