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