@desplega.ai/agent-swarm 1.76.2 → 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.
- package/openapi.json +9 -2
- package/package.json +1 -1
- package/src/be/memory/raters/llm.ts +26 -0
- package/src/hooks/hook.ts +174 -147
- package/src/http/status.ts +8 -0
- package/src/providers/claude-adapter.ts +9 -1
- package/src/providers/codex-adapter.ts +232 -2
- package/src/providers/codex-oauth/storage.ts +21 -0
- package/src/providers/pi-mono-extension.ts +114 -77
- package/src/telemetry.ts +28 -0
- package/src/tests/claude-stop-hook.test.ts +432 -0
- package/src/tests/codex-adapter.test.ts +436 -1
- package/src/tests/internal-ai/complete-structured.test.ts +276 -0
- package/src/tests/internal-ai/credentials.test.ts +264 -0
- package/src/tests/internal-ai/schema-parity.test.ts +103 -0
- package/src/tests/internal-ai/summarize-session.test.ts +105 -0
- package/src/tests/opencode-plugin.test.ts +496 -0
- package/src/tests/pi-mono-extension.test.ts +347 -0
- package/src/tests/reload-config.test.ts +9 -1
- package/src/tests/status.test.ts +4 -0
- package/src/tests/telemetry-init.test.ts +137 -1
- package/src/tests/template-recommendations.test.ts +1 -0
- package/src/utils/internal-ai/complete-structured.ts +296 -0
- package/src/utils/internal-ai/credentials.ts +175 -0
- package/src/utils/internal-ai/index.ts +31 -0
- package/src/utils/internal-ai/models.ts +46 -0
- package/src/utils/internal-ai/summarize-session.ts +101 -0
|
@@ -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
|
+
});
|
|
@@ -225,7 +225,15 @@ describe("auto-reload debouncer", () => {
|
|
|
225
225
|
}
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
-
beforeEach(() => {
|
|
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();
|
|
229
237
|
_resetAutoReloadForTests();
|
|
230
238
|
});
|
|
231
239
|
|
package/src/tests/status.test.ts
CHANGED
|
@@ -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
|
|