@desplega.ai/agent-swarm 1.76.2 → 1.77.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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Unit tests for `runStopHookSessionSummary` in `src/hooks/hook.ts`.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
+ * → Phase 4 § "Test coverage"
6
+ *
7
+ * Uses explicit dependency injection (the `deps` parameter on
8
+ * `runStopHookSessionSummary`) instead of `bun:test`'s `mock.module()` because
9
+ * the latter installs a process-wide override that leaks across test files in
10
+ * the same `bun test` run. Mirrors the `summarizeSessionForPi` test pattern in
11
+ * `src/tests/pi-mono-extension.test.ts`.
12
+ *
13
+ * Mocks:
14
+ * - `runSummarize` — captures args + returns canned result
15
+ * - `fetchRetrievalsForTask` — returns canned retrievals (when needed)
16
+ * - `postRatings` — captures args, asserts `events:` key
17
+ * - `buildRatingsFromLlm` — minimal pass-through unless overridden
18
+ * - `globalThis.fetch` — captures `/api/memory/index` POSTs
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
+ import type { RunStopHookSessionSummaryDeps } from "../hooks/hook";
23
+ import { runStopHookSessionSummary } from "../hooks/hook";
24
+
25
+ // ── helpers ───────────────────────────────────────────────────────────────────
26
+
27
+ /** Build a transcript with > 100 chars so the degenerate gate doesn't trip. */
28
+ function longTranscript(extra = "") {
29
+ return "User: do a thing\nAssistant: doing thing\nTool[write]: ok\n".repeat(5) + extra;
30
+ }
31
+
32
+ /**
33
+ * Write a temp file under /tmp containing `content`. The SUT's
34
+ * `Bun.file(transcriptPath).text()` reads it back without further mocking.
35
+ */
36
+ async function writeTempTranscript(content: string): Promise<string> {
37
+ const path = `/tmp/claude-stop-hook-test-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`;
38
+ await Bun.write(path, content);
39
+ return path;
40
+ }
41
+
42
+ // ── test state ────────────────────────────────────────────────────────────────
43
+
44
+ type RunSummarizeArgs = Parameters<NonNullable<RunStopHookSessionSummaryDeps["runSummarize"]>>[0];
45
+ type RunSummarizeResult = Awaited<
46
+ ReturnType<NonNullable<RunStopHookSessionSummaryDeps["runSummarize"]>>
47
+ >;
48
+ type PostRatingsArgs = Parameters<NonNullable<RunStopHookSessionSummaryDeps["postRatings"]>>[0];
49
+
50
+ const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
51
+ type FetchHandlerResp = {
52
+ ok: boolean;
53
+ status: number;
54
+ text: () => Promise<string>;
55
+ json: () => Promise<unknown>;
56
+ };
57
+ let fetchHandler: ((url: string, init?: RequestInit) => Promise<FetchHandlerResp>) | null = null;
58
+ const consoleErrors: unknown[][] = [];
59
+
60
+ const origFetch = globalThis.fetch;
61
+ const origConsoleError = console.error;
62
+
63
+ beforeEach(() => {
64
+ fetchCalls.length = 0;
65
+ consoleErrors.length = 0;
66
+ fetchHandler = null;
67
+ // Default fetch: 202 for /api/memory/index, 200 otherwise.
68
+ fetchHandler = async (url) => {
69
+ if (url.includes("/api/memory/index")) {
70
+ return {
71
+ ok: true,
72
+ status: 202,
73
+ text: async () => "",
74
+ json: async () => ({ queued: true, memoryIds: ["mem-1"] }),
75
+ };
76
+ }
77
+ return { ok: true, status: 200, text: async () => "", json: async () => ({}) };
78
+ };
79
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
80
+ const urlStr = typeof url === "string" ? url : url.toString();
81
+ fetchCalls.push({ url: urlStr, init });
82
+ if (!fetchHandler) return new Response("{}", { status: 200 });
83
+ return fetchHandler(urlStr, init) as unknown as Response;
84
+ }) as typeof fetch;
85
+ console.error = (...args: unknown[]) => {
86
+ consoleErrors.push(args);
87
+ };
88
+ // Wipe any envs that could leak between tests.
89
+ delete process.env.MEMORY_RATERS;
90
+ delete process.env.SKIP_SESSION_SUMMARY;
91
+ });
92
+
93
+ afterEach(() => {
94
+ globalThis.fetch = origFetch;
95
+ console.error = origConsoleError;
96
+ });
97
+
98
+ function makeEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
99
+ // Minimal env that drives the SUT through its happy path. Tests override
100
+ // selectively via `extra`.
101
+ return {
102
+ MCP_BASE_URL: "http://localhost:3013",
103
+ API_KEY: "test-key",
104
+ AGENT_SWARM_TASK_ID: "task-stop-1",
105
+ ...extra,
106
+ };
107
+ }
108
+
109
+ // ── tests ─────────────────────────────────────────────────────────────────────
110
+
111
+ describe("runStopHookSessionSummary", () => {
112
+ test("happy path — long transcript + valid summary → POSTs to /api/memory/index with old runMemoryRater shape", async () => {
113
+ const transcript = longTranscript("Real-looking learnings here\n");
114
+ const transcriptPath = await writeTempTranscript(transcript);
115
+
116
+ let lastRunSummarizeArgs: RunSummarizeArgs | null = null;
117
+ const deps: RunStopHookSessionSummaryDeps = {
118
+ runSummarize: async (args) => {
119
+ lastRunSummarizeArgs = args;
120
+ return {
121
+ summary: "Learned X about Y — concrete reusable fact.",
122
+ ratings: [],
123
+ } as RunSummarizeResult;
124
+ },
125
+ };
126
+
127
+ await runStopHookSessionSummary(
128
+ {
129
+ agentId: "agent-claude-1",
130
+ transcriptPath,
131
+ env: makeEnv(),
132
+ },
133
+ deps,
134
+ );
135
+
136
+ expect(lastRunSummarizeArgs).not.toBeNull();
137
+ expect(lastRunSummarizeArgs!.harness).toBe("claude");
138
+ expect(lastRunSummarizeArgs!.taskContext.sourceTaskId).toBe("task-stop-1");
139
+ expect(lastRunSummarizeArgs!.taskContext.agentId).toBe("agent-claude-1");
140
+ expect(lastRunSummarizeArgs!.apiUrl).toBe("http://localhost:3013");
141
+ expect(lastRunSummarizeArgs!.apiKey).toBe("test-key");
142
+
143
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
144
+ expect(indexCalls.length).toBe(1);
145
+ const body = JSON.parse(indexCalls[0]!.init?.body as string) as Record<string, unknown>;
146
+ expect(body.scope).toBe("agent");
147
+ expect(body.source).toBe("session_summary");
148
+ expect(body.sourceTaskId).toBe("task-stop-1");
149
+ expect(body.agentId).toBe("agent-claude-1");
150
+ expect(typeof body.name).toBe("string");
151
+ expect((body.name as string).length).toBeGreaterThan(0);
152
+ expect(body.content).toBe("Learned X about Y — concrete reusable fact.");
153
+
154
+ // Headers match the old runMemoryRater POST: Bearer + X-Agent-ID.
155
+ const headers = indexCalls[0]!.init?.headers as Record<string, string>;
156
+ expect(headers["Content-Type"]).toBe("application/json");
157
+ expect(headers.Authorization).toBe("Bearer test-key");
158
+ expect(headers["X-Agent-ID"]).toBe("agent-claude-1");
159
+
160
+ expect(consoleErrors.length).toBe(0);
161
+ });
162
+
163
+ test("no credentials (runSummarize returns null) → no POST, no exception, no error log", async () => {
164
+ const transcriptPath = await writeTempTranscript(longTranscript());
165
+
166
+ const deps: RunStopHookSessionSummaryDeps = {
167
+ runSummarize: async () => null,
168
+ };
169
+
170
+ await runStopHookSessionSummary(
171
+ {
172
+ agentId: "agent-claude-1",
173
+ transcriptPath,
174
+ env: makeEnv(),
175
+ },
176
+ deps,
177
+ );
178
+
179
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
180
+ expect(indexCalls.length).toBe(0);
181
+ expect(consoleErrors.length).toBe(0);
182
+ });
183
+
184
+ test("CLAUDE_CODE_OAUTH_TOKEN-only env → wrapper resolves claude-cli; POST still happens", async () => {
185
+ // The SUT delegates credential resolution to `runSummarize` (which calls
186
+ // `resolveCredential` internally). We exercise the same code path by
187
+ // injecting a `runSummarize` that asserts the env state and returns a
188
+ // canned `{summary, ratings}`. Mirrors what the real wrapper would return
189
+ // after going through the claude-cli fallback.
190
+ const transcriptPath = await writeTempTranscript(longTranscript("oauth fallback exercise\n"));
191
+
192
+ let observedEnvHasOAuth = false;
193
+ const deps: RunStopHookSessionSummaryDeps = {
194
+ runSummarize: async () => {
195
+ observedEnvHasOAuth =
196
+ !!process.env.CLAUDE_CODE_OAUTH_TOKEN &&
197
+ !process.env.OPENROUTER_API_KEY &&
198
+ !process.env.ANTHROPIC_API_KEY &&
199
+ !process.env.OPENAI_API_KEY;
200
+ return {
201
+ summary:
202
+ "OAuth-fallback session: identified the silent-drop root cause and shipped a fix.",
203
+ ratings: [],
204
+ } as RunSummarizeResult;
205
+ },
206
+ };
207
+
208
+ // Set only CLAUDE_CODE_OAUTH_TOKEN; ensure others are not present in the
209
+ // PROCESS env (the SUT's `runSummarize` reads `process.env` because the
210
+ // wrapper's `resolveCredential` defaults to `process.env`).
211
+ const prev = {
212
+ OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
213
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
214
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
215
+ CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
216
+ };
217
+ delete process.env.OPENROUTER_API_KEY;
218
+ delete process.env.ANTHROPIC_API_KEY;
219
+ delete process.env.OPENAI_API_KEY;
220
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-test-oauth-stop-hook";
221
+
222
+ try {
223
+ await runStopHookSessionSummary(
224
+ {
225
+ agentId: "agent-claude-oauth",
226
+ transcriptPath,
227
+ env: makeEnv({
228
+ // Mirror the process env into the SUT-scoped env for SKIP / MCP_BASE_URL plumbing.
229
+ CLAUDE_CODE_OAUTH_TOKEN: "sk-test-oauth-stop-hook",
230
+ }),
231
+ },
232
+ deps,
233
+ );
234
+
235
+ expect(observedEnvHasOAuth).toBe(true);
236
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
237
+ expect(indexCalls.length).toBe(1);
238
+ const body = JSON.parse(indexCalls[0]!.init?.body as string) as Record<string, unknown>;
239
+ expect(body.source).toBe("session_summary");
240
+ expect(body.sourceTaskId).toBe("task-stop-1");
241
+ expect(body.agentId).toBe("agent-claude-oauth");
242
+ } finally {
243
+ // Restore process env.
244
+ if (prev.OPENROUTER_API_KEY === undefined) delete process.env.OPENROUTER_API_KEY;
245
+ else process.env.OPENROUTER_API_KEY = prev.OPENROUTER_API_KEY;
246
+ if (prev.ANTHROPIC_API_KEY === undefined) delete process.env.ANTHROPIC_API_KEY;
247
+ else process.env.ANTHROPIC_API_KEY = prev.ANTHROPIC_API_KEY;
248
+ if (prev.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY;
249
+ else process.env.OPENAI_API_KEY = prev.OPENAI_API_KEY;
250
+ if (prev.CLAUDE_CODE_OAUTH_TOKEN === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
251
+ else process.env.CLAUDE_CODE_OAUTH_TOKEN = prev.CLAUDE_CODE_OAUTH_TOKEN;
252
+ }
253
+ });
254
+
255
+ test("SKIP_SESSION_SUMMARY=1 → no runSummarize call, no POST", async () => {
256
+ const transcriptPath = await writeTempTranscript(longTranscript());
257
+
258
+ let runSummarizeCalled = false;
259
+ const deps: RunStopHookSessionSummaryDeps = {
260
+ runSummarize: async () => {
261
+ runSummarizeCalled = true;
262
+ throw new Error("should not be called");
263
+ },
264
+ };
265
+
266
+ await runStopHookSessionSummary(
267
+ {
268
+ agentId: "agent-claude-1",
269
+ transcriptPath,
270
+ env: makeEnv({ SKIP_SESSION_SUMMARY: "1" }),
271
+ },
272
+ deps,
273
+ );
274
+
275
+ expect(runSummarizeCalled).toBe(false);
276
+ expect(fetchCalls.length).toBe(0);
277
+ expect(consoleErrors.length).toBe(0);
278
+ });
279
+
280
+ test("short transcript (≤100 chars) → no runSummarize call, no POST", async () => {
281
+ const transcriptPath = await writeTempTranscript("tiny");
282
+
283
+ let runSummarizeCalled = false;
284
+ const deps: RunStopHookSessionSummaryDeps = {
285
+ runSummarize: async () => {
286
+ runSummarizeCalled = true;
287
+ throw new Error("should not be called");
288
+ },
289
+ };
290
+
291
+ await runStopHookSessionSummary(
292
+ {
293
+ agentId: "agent-claude-1",
294
+ transcriptPath,
295
+ env: makeEnv(),
296
+ },
297
+ deps,
298
+ );
299
+
300
+ expect(runSummarizeCalled).toBe(false);
301
+ expect(fetchCalls.length).toBe(0);
302
+ expect(consoleErrors.length).toBe(0);
303
+ });
304
+
305
+ test("length gate — summary too short → no POST", async () => {
306
+ const transcriptPath = await writeTempTranscript(longTranscript());
307
+
308
+ const deps: RunStopHookSessionSummaryDeps = {
309
+ runSummarize: async () => ({ summary: "tiny", ratings: [] }) as RunSummarizeResult,
310
+ };
311
+
312
+ await runStopHookSessionSummary(
313
+ {
314
+ agentId: "agent-claude-1",
315
+ transcriptPath,
316
+ env: makeEnv(),
317
+ },
318
+ deps,
319
+ );
320
+
321
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
322
+ expect(indexCalls.length).toBe(0);
323
+ expect(consoleErrors.length).toBe(0);
324
+ });
325
+
326
+ test("'No significant learnings' gate → no POST", async () => {
327
+ const transcriptPath = await writeTempTranscript(longTranscript());
328
+
329
+ const deps: RunStopHookSessionSummaryDeps = {
330
+ runSummarize: async () =>
331
+ ({ summary: "No significant learnings.", ratings: [] }) as RunSummarizeResult,
332
+ };
333
+
334
+ await runStopHookSessionSummary(
335
+ {
336
+ agentId: "agent-claude-1",
337
+ transcriptPath,
338
+ env: makeEnv(),
339
+ },
340
+ deps,
341
+ );
342
+
343
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
344
+ expect(indexCalls.length).toBe(0);
345
+ expect(consoleErrors.length).toBe(0);
346
+ });
347
+
348
+ test("MEMORY_RATERS includes 'llm' + ratings returned → postRatings invoked with events: key", async () => {
349
+ const transcriptPath = await writeTempTranscript(longTranscript("with ratings\n"));
350
+
351
+ let postRatingsArgs: PostRatingsArgs | null = null;
352
+ const fetchRetrievalsArgsLog: unknown[] = [];
353
+
354
+ const deps: RunStopHookSessionSummaryDeps = {
355
+ runSummarize: async () =>
356
+ ({
357
+ summary: "Real, durable learning that easily passes the 20-char gate.",
358
+ ratings: [{ id: "mem-1", score: 0.9, reasoning: "directly applicable" }],
359
+ }) as RunSummarizeResult,
360
+ fetchRetrievalsForTask: async (args) => {
361
+ fetchRetrievalsArgsLog.push(args);
362
+ return [
363
+ {
364
+ id: "mem-1",
365
+ name: "stub memory",
366
+ content: "stub memory content",
367
+ scope: "agent",
368
+ },
369
+ ];
370
+ },
371
+ buildRatingsFromLlm: (ratings, _retrievals) =>
372
+ ratings.map((r) => ({
373
+ memoryId: r.id,
374
+ signal: 2 * r.score - 1,
375
+ weight: 1,
376
+ source: "llm" as const,
377
+ reasoning: r.reasoning,
378
+ })),
379
+ postRatings: async (args) => {
380
+ postRatingsArgs = args;
381
+ return { ok: true, status: 202 };
382
+ },
383
+ };
384
+
385
+ process.env.MEMORY_RATERS = "llm";
386
+ try {
387
+ await runStopHookSessionSummary(
388
+ {
389
+ agentId: "agent-claude-1",
390
+ transcriptPath,
391
+ env: makeEnv(),
392
+ },
393
+ deps,
394
+ );
395
+ } finally {
396
+ delete process.env.MEMORY_RATERS;
397
+ }
398
+
399
+ expect(fetchRetrievalsArgsLog.length).toBe(1);
400
+ expect(postRatingsArgs).not.toBeNull();
401
+ // Real signature uses `events:`, NOT `ratings:`.
402
+ expect(Array.isArray(postRatingsArgs!.events)).toBe(true);
403
+ expect(postRatingsArgs!.events.length).toBe(1);
404
+ expect(postRatingsArgs!.events[0]!.memoryId).toBe("mem-1");
405
+ expect(postRatingsArgs!.taskId).toBe("task-stop-1");
406
+ expect(postRatingsArgs!.agentId).toBe("agent-claude-1");
407
+ });
408
+
409
+ test("runSummarize throws → caught silently; no POST, no rethrow", async () => {
410
+ const transcriptPath = await writeTempTranscript(longTranscript());
411
+
412
+ const deps: RunStopHookSessionSummaryDeps = {
413
+ runSummarize: async () => {
414
+ throw new Error("boom");
415
+ },
416
+ };
417
+
418
+ await expect(
419
+ runStopHookSessionSummary(
420
+ {
421
+ agentId: "agent-claude-1",
422
+ transcriptPath,
423
+ env: makeEnv(),
424
+ },
425
+ deps,
426
+ ),
427
+ ).resolves.toBeUndefined();
428
+
429
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
430
+ expect(indexCalls.length).toBe(0);
431
+ });
432
+ });