@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.
- package/openapi.json +11 -4
- package/package.json +1 -1
- package/src/be/memory/raters/llm.ts +26 -0
- package/src/hooks/hook.ts +174 -147
- package/src/http/config.ts +15 -3
- package/src/http/core.ts +108 -0
- 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 +151 -3
- 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
|
+
});
|
|
@@ -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 {
|
|
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
|
+
});
|
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
|
|