@desplega.ai/agent-swarm 1.76.1 → 1.76.3

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,347 @@
1
+ /**
2
+ * Unit tests for `summarizeSessionForPi` in `src/providers/pi-mono-extension.ts`.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
+ * → Phase 1 § "Test coverage"
6
+ *
7
+ * Uses explicit dependency injection (the `deps` parameter on
8
+ * `summarizeSessionForPi`) instead of `bun:test`'s `mock.module()` because the
9
+ * latter installs a process-wide override that leaks across test files in the
10
+ * same `bun test` run (`buildRatingsFromLlm` siblings + Phase-0 internal-ai
11
+ * tests would break).
12
+ *
13
+ * Mocks:
14
+ * - `runSummarize` — captures args + returns canned result
15
+ * - `fetchRetrievalsForTask` — returns canned retrievals
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 { SummarizeSessionForPiDeps, SwarmHooksConfig } from "../providers/pi-mono-extension";
23
+ import { summarizeSessionForPi } from "../providers/pi-mono-extension";
24
+
25
+ // ── helpers ───────────────────────────────────────────────────────────────────
26
+
27
+ function makeConfig(): SwarmHooksConfig {
28
+ return {
29
+ apiUrl: "http://localhost:3013",
30
+ apiKey: "test-key",
31
+ agentId: "agent-pi-1",
32
+ taskId: "task-pi-1",
33
+ isLead: false,
34
+ };
35
+ }
36
+
37
+ /** Build a transcript with > 100 chars so the degenerate gate doesn't trip. */
38
+ function longTranscript(extra = "") {
39
+ return "User: do a thing\nAssistant: doing thing\nTool[write]: ok\n".repeat(5) + extra;
40
+ }
41
+
42
+ /**
43
+ * Write a temp file under /tmp containing `content`. The SUT's
44
+ * `Bun.file(sessionFile).text()` reads it back without further mocking.
45
+ */
46
+ async function writeTempTranscript(content: string): Promise<string> {
47
+ const path = `/tmp/pi-mono-test-transcript-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`;
48
+ await Bun.write(path, content);
49
+ return path;
50
+ }
51
+
52
+ // ── test state ────────────────────────────────────────────────────────────────
53
+
54
+ type RunSummarizeArgs = Parameters<NonNullable<SummarizeSessionForPiDeps["runSummarize"]>>[0];
55
+ type RunSummarizeResult = Awaited<
56
+ ReturnType<NonNullable<SummarizeSessionForPiDeps["runSummarize"]>>
57
+ >;
58
+ type FetchRetrievalsArgs = Parameters<
59
+ NonNullable<SummarizeSessionForPiDeps["fetchRetrievalsForTask"]>
60
+ >[0];
61
+ type FetchRetrievalsResult = Awaited<
62
+ ReturnType<NonNullable<SummarizeSessionForPiDeps["fetchRetrievalsForTask"]>>
63
+ >;
64
+ type PostRatingsArgs = Parameters<NonNullable<SummarizeSessionForPiDeps["postRatings"]>>[0];
65
+
66
+ const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
67
+ type FetchHandlerResp = {
68
+ ok: boolean;
69
+ status: number;
70
+ text: () => Promise<string>;
71
+ json: () => Promise<unknown>;
72
+ };
73
+ let fetchHandler: ((url: string, init?: RequestInit) => Promise<FetchHandlerResp>) | null = null;
74
+ const consoleErrors: unknown[][] = [];
75
+
76
+ const origFetch = globalThis.fetch;
77
+ const origConsoleError = console.error;
78
+
79
+ beforeEach(() => {
80
+ fetchCalls.length = 0;
81
+ consoleErrors.length = 0;
82
+ fetchHandler = null;
83
+ // Default fetch: 202 for /api/memory/index, 200 otherwise (so non-test fetches
84
+ // like fetchTaskDetails don't crash with an undefined handler).
85
+ fetchHandler = async (url) => {
86
+ if (url.includes("/api/memory/index")) {
87
+ return {
88
+ ok: true,
89
+ status: 202,
90
+ text: async () => "",
91
+ json: async () => ({ queued: true, memoryIds: ["mem-1"] }),
92
+ };
93
+ }
94
+ return { ok: true, status: 200, text: async () => "", json: async () => ({}) };
95
+ };
96
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
97
+ const urlStr = typeof url === "string" ? url : url.toString();
98
+ fetchCalls.push({ url: urlStr, init });
99
+ if (!fetchHandler) return new Response("{}", { status: 200 });
100
+ return fetchHandler(urlStr, init) as unknown as Response;
101
+ }) as typeof fetch;
102
+ console.error = (...args: unknown[]) => {
103
+ consoleErrors.push(args);
104
+ };
105
+ delete process.env.MEMORY_RATERS;
106
+ });
107
+
108
+ afterEach(() => {
109
+ globalThis.fetch = origFetch;
110
+ console.error = origConsoleError;
111
+ });
112
+
113
+ // ── tests ─────────────────────────────────────────────────────────────────────
114
+
115
+ describe("summarizeSessionForPi", () => {
116
+ test("happy path — long transcript + valid summary → POSTs to /api/memory/index", async () => {
117
+ const transcript = longTranscript("Some real-looking work here\n");
118
+ const sessionFile = await writeTempTranscript(transcript);
119
+
120
+ let lastRunSummarizeArgs: RunSummarizeArgs | null = null;
121
+ const deps: SummarizeSessionForPiDeps = {
122
+ runSummarize: async (args) => {
123
+ lastRunSummarizeArgs = args;
124
+ return {
125
+ summary: "Learned X about Y — concrete reusable fact.",
126
+ ratings: [],
127
+ } as RunSummarizeResult;
128
+ },
129
+ };
130
+
131
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
132
+
133
+ expect(lastRunSummarizeArgs).not.toBeNull();
134
+ expect(lastRunSummarizeArgs!.harness).toBe("pi");
135
+ expect(lastRunSummarizeArgs!.taskContext.sourceTaskId).toBe("task-pi-1");
136
+ expect(lastRunSummarizeArgs!.taskContext.agentId).toBe("agent-pi-1");
137
+ expect(lastRunSummarizeArgs!.apiUrl).toBe("http://localhost:3013");
138
+ expect(lastRunSummarizeArgs!.apiKey).toBe("test-key");
139
+
140
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
141
+ expect(indexCalls.length).toBe(1);
142
+ const body = JSON.parse(indexCalls[0]!.init?.body as string) as Record<string, unknown>;
143
+ expect(body.scope).toBe("agent");
144
+ expect(body.source).toBe("session_summary");
145
+ expect(body.sourceTaskId).toBe("task-pi-1");
146
+ expect(body.agentId).toBe("agent-pi-1");
147
+ expect(body.name).toBe("session-summary");
148
+ expect(body.content).toBe("Learned X about Y — concrete reusable fact.");
149
+
150
+ expect(consoleErrors.length).toBe(0);
151
+ });
152
+
153
+ test("empty transcript (≤100 chars) → no POST, no error", async () => {
154
+ const sessionFile = await writeTempTranscript("short");
155
+
156
+ const deps: SummarizeSessionForPiDeps = {
157
+ runSummarize: async () => {
158
+ throw new Error("should not be called");
159
+ },
160
+ };
161
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
162
+
163
+ expect(fetchCalls.length).toBe(0);
164
+ expect(consoleErrors.length).toBe(0);
165
+ });
166
+
167
+ test("no sessionFile → no POST, no error", async () => {
168
+ const deps: SummarizeSessionForPiDeps = {
169
+ runSummarize: async () => {
170
+ throw new Error("should not be called");
171
+ },
172
+ };
173
+ await summarizeSessionForPi(makeConfig(), undefined, deps);
174
+
175
+ expect(fetchCalls.length).toBe(0);
176
+ expect(consoleErrors.length).toBe(0);
177
+ });
178
+
179
+ test("no credentials (runSummarize returns null) → no POST, no error log", async () => {
180
+ const sessionFile = await writeTempTranscript(longTranscript());
181
+
182
+ const deps: SummarizeSessionForPiDeps = {
183
+ runSummarize: async () => null,
184
+ };
185
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
186
+
187
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
188
+ expect(indexCalls.length).toBe(0);
189
+ // wrapper logs internally; the pi wrapper itself should not log on null return
190
+ expect(consoleErrors.length).toBe(0);
191
+ });
192
+
193
+ test("length gate — summary too short → no POST", async () => {
194
+ const sessionFile = await writeTempTranscript(longTranscript());
195
+
196
+ const deps: SummarizeSessionForPiDeps = {
197
+ runSummarize: async () => ({ summary: "tiny", ratings: [] }) as RunSummarizeResult,
198
+ };
199
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
200
+
201
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
202
+ expect(indexCalls.length).toBe(0);
203
+ expect(consoleErrors.length).toBe(0);
204
+ });
205
+
206
+ test("'no significant learnings' gate → no POST", async () => {
207
+ const sessionFile = await writeTempTranscript(longTranscript());
208
+
209
+ const deps: SummarizeSessionForPiDeps = {
210
+ runSummarize: async () =>
211
+ ({ summary: "No significant learnings.", ratings: [] }) as RunSummarizeResult,
212
+ };
213
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
214
+
215
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
216
+ expect(indexCalls.length).toBe(0);
217
+ expect(consoleErrors.length).toBe(0);
218
+ });
219
+
220
+ test("POST 500 → exactly one console.error('session_summary: /api/memory/index POST failed (pi):', ...)", async () => {
221
+ const sessionFile = await writeTempTranscript(longTranscript());
222
+
223
+ fetchHandler = async (url) => {
224
+ if (url.includes("/api/memory/index")) {
225
+ return {
226
+ ok: false,
227
+ status: 500,
228
+ text: async () => "internal server error",
229
+ json: async () => ({}),
230
+ };
231
+ }
232
+ return { ok: true, status: 200, text: async () => "", json: async () => ({}) };
233
+ };
234
+
235
+ const deps: SummarizeSessionForPiDeps = {
236
+ runSummarize: async () =>
237
+ ({
238
+ summary: "A valid long-enough summary that passes the length gate.",
239
+ ratings: [],
240
+ }) as RunSummarizeResult,
241
+ };
242
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
243
+
244
+ const matching = consoleErrors.filter(
245
+ (args) =>
246
+ typeof args[0] === "string" &&
247
+ (args[0] as string).startsWith("session_summary: /api/memory/index POST failed (pi):"),
248
+ );
249
+ expect(matching.length).toBe(1);
250
+ expect(matching[0]![1]).toBe(500);
251
+ });
252
+
253
+ test("fetch throws → exactly one console.error('session_summary failed (pi):', ...)", async () => {
254
+ const sessionFile = await writeTempTranscript(longTranscript());
255
+
256
+ fetchHandler = async (url) => {
257
+ if (url.includes("/api/memory/index")) {
258
+ throw new Error("network down");
259
+ }
260
+ return { ok: true, status: 200, text: async () => "", json: async () => ({}) };
261
+ };
262
+
263
+ const deps: SummarizeSessionForPiDeps = {
264
+ runSummarize: async () =>
265
+ ({
266
+ summary: "A valid long-enough summary that passes the length gate.",
267
+ ratings: [],
268
+ }) as RunSummarizeResult,
269
+ };
270
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
271
+
272
+ const matching = consoleErrors.filter(
273
+ (args) =>
274
+ typeof args[0] === "string" &&
275
+ (args[0] as string).startsWith("session_summary failed (pi):"),
276
+ );
277
+ expect(matching.length).toBe(1);
278
+ });
279
+
280
+ test("ratings path — MEMORY_RATERS=llm + retrievals + ratings → postRatings called with `events:` key (NOT `ratings:`)", async () => {
281
+ process.env.MEMORY_RATERS = "llm";
282
+ const sessionFile = await writeTempTranscript(longTranscript());
283
+
284
+ const retrievalRow = {
285
+ id: "mem-A",
286
+ name: "memory A",
287
+ content: "...",
288
+ };
289
+ const fetchRetrievalsMock: SummarizeSessionForPiDeps["fetchRetrievalsForTask"] = async (
290
+ _args: FetchRetrievalsArgs,
291
+ ) => [retrievalRow] as unknown as FetchRetrievalsResult;
292
+
293
+ let lastPostRatingsArgs: PostRatingsArgs | null = null;
294
+ const postRatingsMock: SummarizeSessionForPiDeps["postRatings"] = async (args) => {
295
+ lastPostRatingsArgs = args;
296
+ return { ok: true, status: 200 };
297
+ };
298
+
299
+ const deps: SummarizeSessionForPiDeps = {
300
+ runSummarize: async (args) => {
301
+ expect(args.retrievals.length).toBe(1);
302
+ expect(args.retrievals[0]!.id).toBe("mem-A");
303
+ return {
304
+ summary: "Long-enough summary with real content for the index POST.",
305
+ ratings: [{ id: "mem-A", score: 0.8, reasoning: "useful" }],
306
+ } as RunSummarizeResult;
307
+ },
308
+ fetchRetrievalsForTask: fetchRetrievalsMock,
309
+ postRatings: postRatingsMock,
310
+ buildRatingsFromLlm: (ratings, retrievals) => {
311
+ // Smoke-check: only keep ratings present in retrievals (mirrors real impl)
312
+ const allowed = new Set(retrievals.map((r) => r.id));
313
+ return ratings
314
+ .filter((r) => allowed.has(r.id))
315
+ .map((r) => ({
316
+ memoryId: r.id,
317
+ signal: 2 * r.score - 1,
318
+ weight: 0.8,
319
+ source: "llm",
320
+ reasoning: r.reasoning,
321
+ }));
322
+ },
323
+ };
324
+
325
+ await summarizeSessionForPi(makeConfig(), sessionFile, deps);
326
+
327
+ // Index POST happened
328
+ const indexCalls = fetchCalls.filter((c) => c.url.endsWith("/api/memory/index"));
329
+ expect(indexCalls.length).toBe(1);
330
+
331
+ // postRatings was called with `events:` key, not `ratings:` — guards against
332
+ // the orchestrator-flagged plan/signature mismatch
333
+ expect(lastPostRatingsArgs).not.toBeNull();
334
+ expect(lastPostRatingsArgs!.apiUrl).toBe("http://localhost:3013");
335
+ expect(lastPostRatingsArgs!.agentId).toBe("agent-pi-1");
336
+ expect(lastPostRatingsArgs!.taskId).toBe("task-pi-1");
337
+ expect(Array.isArray(lastPostRatingsArgs!.events)).toBe(true);
338
+ expect(lastPostRatingsArgs!.events.length).toBe(1);
339
+ expect(lastPostRatingsArgs!.events[0]!.memoryId).toBe("mem-A");
340
+ expect(lastPostRatingsArgs!.events[0]!.source).toBe("llm");
341
+
342
+ // Guard against accidentally passing a `ratings:` key (plan example bug)
343
+ expect((lastPostRatingsArgs as unknown as Record<string, unknown>).ratings).toBeUndefined();
344
+
345
+ expect(consoleErrors.length).toBe(0);
346
+ });
347
+ });
@@ -1,10 +1,16 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { createServer as createHttpServer, type Server } from "node:http";
4
4
  import { initAgentMail, resetAgentMail } from "../agentmail";
5
- import { closeDb, getDb, initDb, upsertSwarmConfig } from "../be/db";
5
+ import { closeDb, deleteSwarmConfig, getDb, initDb, upsertSwarmConfig } from "../be/db";
6
6
  import { initGitHub, resetGitHub } from "../github";
7
- import { loadGlobalConfigsIntoEnv } from "../http/core";
7
+ import {
8
+ _autoReloadStatsForTests,
9
+ _resetAutoReloadForTests,
10
+ flushPendingIntegrationsReload,
11
+ loadGlobalConfigsIntoEnv,
12
+ scheduleIntegrationsReload,
13
+ } from "../http/core";
8
14
 
9
15
  const TEST_DB_PATH = "./test-reload-config.sqlite";
10
16
  const TEST_PORT = 13023;
@@ -199,3 +205,145 @@ describe("reload-config", () => {
199
205
  expect(res.status).toBe(404);
200
206
  });
201
207
  });
208
+
209
+ describe("auto-reload debouncer", () => {
210
+ // The reload calls into stopSlackApp/startSlackApp + GH/Linear/Jira/AgentMail
211
+ // init. They are no-ops without credentials, so we explicitly disable Slack
212
+ // (it has its own DISABLE switch) and rely on the others being unconfigured.
213
+ let originalSlackDisable: string | undefined;
214
+
215
+ beforeAll(() => {
216
+ originalSlackDisable = process.env.SLACK_DISABLE;
217
+ process.env.SLACK_DISABLE = "true";
218
+ });
219
+
220
+ afterAll(() => {
221
+ if (originalSlackDisable === undefined) {
222
+ delete process.env.SLACK_DISABLE;
223
+ } else {
224
+ process.env.SLACK_DISABLE = originalSlackDisable;
225
+ }
226
+ });
227
+
228
+ beforeEach(async () => {
229
+ // Drain any reload state that leaked from earlier test files in the full
230
+ // suite (e.g. swarm-config-reserved-keys.test.ts does global PUT/DELETE on
231
+ // /api/config, which schedules a 250ms reload). If we reset() while a
232
+ // prior timer was still mid-flight, the leaked .finally() can race against
233
+ // our test body and stomp the module state — first symptom is
234
+ // `expect(pending).toBe(true)` failing because `inFlightReload` was still
235
+ // truthy when `scheduleIntegrationsReload` ran. Flush first, then reset.
236
+ await flushPendingIntegrationsReload();
237
+ _resetAutoReloadForTests();
238
+ });
239
+
240
+ test("scheduleIntegrationsReload runs reload after the debounce window", async () => {
241
+ const testKey = `__TEST_AUTO_RELOAD_RUNS_${Date.now()}`;
242
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "fresh" });
243
+ delete process.env[testKey];
244
+
245
+ scheduleIntegrationsReload(50);
246
+ expect(_autoReloadStatsForTests().pending).toBe(true);
247
+
248
+ await flushPendingIntegrationsReload();
249
+
250
+ expect(_autoReloadStatsForTests().invocations).toBe(1);
251
+ expect(process.env[testKey]).toBe("fresh");
252
+
253
+ delete process.env[testKey];
254
+ });
255
+
256
+ test("rapid scheduleIntegrationsReload calls coalesce into one reload", async () => {
257
+ const testKey = `__TEST_COALESCE_${Date.now()}`;
258
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "v1" });
259
+
260
+ scheduleIntegrationsReload(100);
261
+ scheduleIntegrationsReload(100);
262
+ scheduleIntegrationsReload(100);
263
+ scheduleIntegrationsReload(100);
264
+
265
+ expect(_autoReloadStatsForTests().invocations).toBe(0);
266
+
267
+ await flushPendingIntegrationsReload();
268
+
269
+ expect(_autoReloadStatsForTests().invocations).toBe(1);
270
+
271
+ delete process.env[testKey];
272
+ });
273
+
274
+ test("schedule during in-flight reload triggers exactly one rerun", async () => {
275
+ const testKey = `__TEST_RERUN_${Date.now()}`;
276
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "first" });
277
+
278
+ scheduleIntegrationsReload(20);
279
+ // Wait just past the debounce so the first reload is in-flight, then
280
+ // schedule again. The second call should defer to a rerun, not a parallel
281
+ // reload.
282
+ await new Promise((r) => setTimeout(r, 25));
283
+ scheduleIntegrationsReload(20);
284
+ scheduleIntegrationsReload(20); // collapses with the rerun-pending flag
285
+
286
+ await flushPendingIntegrationsReload();
287
+
288
+ // First run + one rerun = 2 invocations total.
289
+ expect(_autoReloadStatsForTests().invocations).toBe(2);
290
+
291
+ delete process.env[testKey];
292
+ });
293
+
294
+ test("flushPendingIntegrationsReload is a no-op when nothing is queued", async () => {
295
+ expect(_autoReloadStatsForTests().pending).toBe(false);
296
+ await flushPendingIntegrationsReload();
297
+ expect(_autoReloadStatsForTests().invocations).toBe(0);
298
+ });
299
+
300
+ test("auto-reload picks up a brand-new config row at runtime", async () => {
301
+ const testKey = `__TEST_NEW_ROW_${Date.now()}`;
302
+ delete process.env[testKey];
303
+
304
+ // Simulate the upsert path's behavior: write the row, then schedule.
305
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "live-update" });
306
+ scheduleIntegrationsReload(20);
307
+
308
+ await flushPendingIntegrationsReload();
309
+
310
+ expect(process.env[testKey]).toBe("live-update");
311
+ delete process.env[testKey];
312
+ });
313
+
314
+ test("auto-reload reflects an updated value (override semantics)", async () => {
315
+ const testKey = `__TEST_OVERRIDE_LIVE_${Date.now()}`;
316
+ process.env[testKey] = "shipped-by-deploy";
317
+
318
+ // Pre-existing env should win at startup, but reload uses override=true.
319
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "from-config" });
320
+ scheduleIntegrationsReload(20);
321
+
322
+ await flushPendingIntegrationsReload();
323
+
324
+ expect(process.env[testKey]).toBe("from-config");
325
+ delete process.env[testKey];
326
+ });
327
+
328
+ test("delete + reload removes value from active env (well, doesn't re-inject it)", async () => {
329
+ const testKey = `__TEST_DELETE_${Date.now()}`;
330
+ delete process.env[testKey];
331
+
332
+ const config = upsertSwarmConfig({ scope: "global", key: testKey, value: "to-be-deleted" });
333
+ scheduleIntegrationsReload(20);
334
+ await flushPendingIntegrationsReload();
335
+ expect(process.env[testKey]).toBe("to-be-deleted");
336
+
337
+ deleteSwarmConfig(config.id);
338
+ // Mimic the delete handler in src/http/config.ts.
339
+ scheduleIntegrationsReload(20);
340
+ await flushPendingIntegrationsReload();
341
+
342
+ // Caveat: process.env keeps the previously-injected value. Reload only
343
+ // overwrites keys that still exist in DB. This test pins that behavior so
344
+ // anyone changing the loader has to make a deliberate decision about
345
+ // whether to also unset removed keys.
346
+ expect(process.env[testKey]).toBe("to-be-deleted");
347
+ delete process.env[testKey];
348
+ });
349
+ });
@@ -77,6 +77,7 @@ async function removeDbFiles(path: string): Promise<void> {
77
77
  const ENV_KEYS_TO_RESET = [
78
78
  "SWARM_CLOUD",
79
79
  "SWARM_ORG_NAME",
80
+ "SWARM_ORG_ID",
80
81
  "SWARM_ORG_LOGO_URL",
81
82
  "SWARM_BRAND_COLOR",
82
83
  "SWARM_MARKETING_URL",
@@ -161,12 +162,14 @@ describe("buildStatusPayload — identity", () => {
161
162
  is_cloud: false,
162
163
  marketing_url: null,
163
164
  hide_cloud_promo: false,
165
+ org_id: null,
164
166
  });
165
167
  });
166
168
 
167
169
  test("reflects SWARM_* envs when all set", () => {
168
170
  process.env.SWARM_CLOUD = "true";
169
171
  process.env.SWARM_ORG_NAME = "Acme";
172
+ process.env.SWARM_ORG_ID = "org_acme_123";
170
173
  process.env.SWARM_ORG_LOGO_URL = "https://acme.example/logo.png";
171
174
  process.env.SWARM_BRAND_COLOR = "#ff5500";
172
175
  process.env.SWARM_MARKETING_URL = "https://swarm.acme.example";
@@ -180,6 +183,7 @@ describe("buildStatusPayload — identity", () => {
180
183
  is_cloud: true,
181
184
  marketing_url: "https://swarm.acme.example",
182
185
  hide_cloud_promo: true,
186
+ org_id: "org_acme_123",
183
187
  });
184
188
  });
185
189
 
@@ -1,8 +1,9 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  _getInstallationIdForTests,
4
4
  _resetTelemetryStateForTests,
5
5
  initTelemetry,
6
+ track,
6
7
  } from "../telemetry";
7
8
 
8
9
  // initTelemetry no-ops when ANONYMIZED_TELEMETRY=false. The CI env or local
@@ -76,6 +77,141 @@ describe("initTelemetry", () => {
76
77
  expect(writes).toEqual([]);
77
78
  });
78
79
 
80
+ describe("track() org identity in metadata", () => {
81
+ const originalFetch = globalThis.fetch;
82
+ let captured: Record<string, unknown> | null = null;
83
+
84
+ beforeEach(() => {
85
+ captured = null;
86
+ globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
87
+ captured = init?.body ? JSON.parse(init.body) : null;
88
+ return new Response(null, { status: 204 });
89
+ }) as typeof fetch;
90
+ });
91
+
92
+ afterEach(() => {
93
+ globalThis.fetch = originalFetch;
94
+ delete process.env.SWARM_ORG_ID;
95
+ delete process.env.SWARM_ORG_NAME;
96
+ delete process.env.SWARM_CLOUD;
97
+ });
98
+
99
+ test("omits organization_* keys from metadata when SWARM_ORG_* unset", async () => {
100
+ delete process.env.SWARM_ORG_ID;
101
+ delete process.env.SWARM_ORG_NAME;
102
+ await initTelemetry(
103
+ "api-server",
104
+ async () => undefined,
105
+ async () => {},
106
+ {
107
+ generateIfMissing: true,
108
+ },
109
+ );
110
+
111
+ track({ event: "test.event", properties: {} });
112
+ // Wait one microtask for the fire-and-forget fetch.
113
+ await new Promise((r) => setTimeout(r, 0));
114
+
115
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
116
+ expect(metadata.organization_id).toBeUndefined();
117
+ expect(metadata.organization_name).toBeUndefined();
118
+ });
119
+
120
+ test("includes organization_id + organization_name when SWARM_ORG_* set", async () => {
121
+ process.env.SWARM_ORG_ID = "org_acme_123";
122
+ process.env.SWARM_ORG_NAME = "Acme Engineering";
123
+ await initTelemetry(
124
+ "api-server",
125
+ async () => undefined,
126
+ async () => {},
127
+ {
128
+ generateIfMissing: true,
129
+ },
130
+ );
131
+
132
+ track({ event: "test.event", properties: {} });
133
+ await new Promise((r) => setTimeout(r, 0));
134
+
135
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
136
+ expect(metadata.organization_id).toBe("org_acme_123");
137
+ expect(metadata.organization_name).toBe("Acme Engineering");
138
+ });
139
+
140
+ test("metadata.is_cloud === false when SWARM_CLOUD unset", async () => {
141
+ delete process.env.SWARM_CLOUD;
142
+ await initTelemetry(
143
+ "api-server",
144
+ async () => undefined,
145
+ async () => {},
146
+ {
147
+ generateIfMissing: true,
148
+ },
149
+ );
150
+
151
+ track({ event: "test.event", properties: {} });
152
+ await new Promise((r) => setTimeout(r, 0));
153
+
154
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
155
+ expect(metadata.is_cloud).toBe(false);
156
+ });
157
+
158
+ test("metadata.is_cloud === true when SWARM_CLOUD=true", async () => {
159
+ process.env.SWARM_CLOUD = "true";
160
+ await initTelemetry(
161
+ "api-server",
162
+ async () => undefined,
163
+ async () => {},
164
+ {
165
+ generateIfMissing: true,
166
+ },
167
+ );
168
+
169
+ track({ event: "test.event", properties: {} });
170
+ await new Promise((r) => setTimeout(r, 0));
171
+
172
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
173
+ expect(metadata.is_cloud).toBe(true);
174
+ });
175
+
176
+ test("metadata.is_cloud === true when SWARM_CLOUD=1 (mirrors buildIdentity)", async () => {
177
+ process.env.SWARM_CLOUD = "1";
178
+ await initTelemetry(
179
+ "api-server",
180
+ async () => undefined,
181
+ async () => {},
182
+ {
183
+ generateIfMissing: true,
184
+ },
185
+ );
186
+
187
+ track({ event: "test.event", properties: {} });
188
+ await new Promise((r) => setTimeout(r, 0));
189
+
190
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
191
+ expect(metadata.is_cloud).toBe(true);
192
+ });
193
+
194
+ test("includes only the keys that are set (org_id alone)", async () => {
195
+ process.env.SWARM_ORG_ID = "org_solo";
196
+ delete process.env.SWARM_ORG_NAME;
197
+ await initTelemetry(
198
+ "api-server",
199
+ async () => undefined,
200
+ async () => {},
201
+ {
202
+ generateIfMissing: true,
203
+ },
204
+ );
205
+
206
+ track({ event: "test.event", properties: {} });
207
+ await new Promise((r) => setTimeout(r, 0));
208
+
209
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
210
+ expect(metadata.organization_id).toBe("org_solo");
211
+ expect(metadata.organization_name).toBeUndefined();
212
+ });
213
+ });
214
+
79
215
  test("existing config → reuses regardless of generateIfMissing flag", async () => {
80
216
  const existing = "install_deadbeefcafebabe";
81
217