@desplega.ai/agent-swarm 1.75.0 → 1.76.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.
- package/README.md +1 -1
- package/openapi.json +973 -36
- package/package.json +2 -2
- package/src/be/db.ts +527 -9
- package/src/be/memory/raters/llm-summarizer.ts +218 -0
- package/src/be/memory/raters/llm.ts +56 -75
- package/src/be/memory/retrieval-store.ts +21 -0
- package/src/be/migrations/054_agent_harness_provider.sql +21 -0
- package/src/be/migrations/055_agent_cred_status.sql +15 -0
- package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
- package/src/be/migrations/057_inbox_item_state.sql +27 -0
- package/src/be/migrations/058_task_templates.sql +31 -0
- package/src/be/swarm-config-guard.ts +24 -0
- package/src/commands/credential-wait.ts +1 -1
- package/src/commands/provider-credentials.ts +434 -0
- package/src/commands/runner.ts +229 -42
- package/src/hooks/hook.ts +115 -95
- package/src/http/agents.ts +82 -2
- package/src/http/config.ts +11 -1
- package/src/http/inbox-state.ts +89 -0
- package/src/http/index.ts +10 -0
- package/src/http/sessions.ts +86 -0
- package/src/http/status.ts +665 -0
- package/src/http/task-templates.ts +51 -0
- package/src/http/tasks.ts +85 -5
- package/src/http/users.ts +134 -0
- package/src/providers/claude-adapter.ts +5 -0
- package/src/providers/codex-adapter.ts +1 -1
- package/src/providers/index.ts +1 -1
- package/src/slack/handlers.ts +0 -1
- package/src/tests/agents-harness-provider.test.ts +333 -0
- package/src/tests/credential-check.test.ts +32 -1
- package/src/tests/credential-status-api.test.ts +42 -0
- package/src/tests/harness-provider-resolution.test.ts +242 -0
- package/src/tests/jira-sync.test.ts +1 -1
- package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
- package/src/tests/memory-rater-llm.test.ts +265 -107
- package/src/tests/migration-runner-regressions.test.ts +17 -2
- package/src/tests/sessions.test.ts +141 -0
- package/src/tests/status.test.ts +843 -0
- package/src/tests/stop-hook-task-resolution.test.ts +98 -0
- package/src/tests/template-recommendations.test.ts +148 -0
- package/src/tests/use-dismissible-card.test.ts +140 -0
- package/src/tools/swarm-config/set-config.ts +17 -1
- package/src/types.ts +117 -0
- package/src/utils/harness-provider.ts +32 -0
- package/tsconfig.json +0 -2
- package/src/providers/credentials.ts +0 -74
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `runMemoryRater` helper extracted from `src/hooks/hook.ts`
|
|
3
|
+
* in the PR #450 review-feedback amendment. The helper owns the OpenRouter
|
|
4
|
+
* direct-HTTP path: request shape (model, `response_format: json_object`,
|
|
5
|
+
* Authorization header), tolerant JSON parse on the assistant content, schema
|
|
6
|
+
* validation, and the env-driven model override.
|
|
7
|
+
*
|
|
8
|
+
* All tests stub `fetch` — no network calls.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_MEMORY_RATER_MODEL,
|
|
13
|
+
getMemoryRaterModel,
|
|
14
|
+
MEMORY_RATER_JSON_SCHEMA,
|
|
15
|
+
MEMORY_RATER_SCHEMA_NAME,
|
|
16
|
+
runMemoryRater,
|
|
17
|
+
tryParseLooseJson,
|
|
18
|
+
} from "../be/memory/raters/llm-summarizer";
|
|
19
|
+
|
|
20
|
+
function makeOpenRouterResponse(content: string, init: ResponseInit = { status: 200 }): Response {
|
|
21
|
+
return new Response(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
choices: [{ message: { role: "assistant", content } }],
|
|
24
|
+
}),
|
|
25
|
+
{
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
...init,
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("getMemoryRaterModel", () => {
|
|
34
|
+
test("returns the default when MEMORY_RATER_MODEL is unset", () => {
|
|
35
|
+
expect(getMemoryRaterModel({})).toBe(DEFAULT_MEMORY_RATER_MODEL);
|
|
36
|
+
expect(DEFAULT_MEMORY_RATER_MODEL).toBe("google/gemini-3-flash-preview");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns the env override when set", () => {
|
|
40
|
+
expect(getMemoryRaterModel({ MEMORY_RATER_MODEL: "anthropic/claude-haiku-4.5" })).toBe(
|
|
41
|
+
"anthropic/claude-haiku-4.5",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("trims whitespace in the env override", () => {
|
|
46
|
+
expect(getMemoryRaterModel({ MEMORY_RATER_MODEL: " openai/gpt-5-mini " })).toBe(
|
|
47
|
+
"openai/gpt-5-mini",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("falls back to the default when env var is empty / whitespace-only", () => {
|
|
52
|
+
expect(getMemoryRaterModel({ MEMORY_RATER_MODEL: "" })).toBe(DEFAULT_MEMORY_RATER_MODEL);
|
|
53
|
+
expect(getMemoryRaterModel({ MEMORY_RATER_MODEL: " " })).toBe(DEFAULT_MEMORY_RATER_MODEL);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("respects process.env when no env arg is provided", () => {
|
|
57
|
+
const prev = process.env.MEMORY_RATER_MODEL;
|
|
58
|
+
process.env.MEMORY_RATER_MODEL = "fake/model-from-process-env";
|
|
59
|
+
try {
|
|
60
|
+
expect(getMemoryRaterModel()).toBe("fake/model-from-process-env");
|
|
61
|
+
} finally {
|
|
62
|
+
if (prev === undefined) delete process.env.MEMORY_RATER_MODEL;
|
|
63
|
+
else process.env.MEMORY_RATER_MODEL = prev;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("tryParseLooseJson", () => {
|
|
69
|
+
test("strict JSON parses unchanged", () => {
|
|
70
|
+
expect(tryParseLooseJson('{"a":1}')).toEqual({ a: 1 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("strips ```json fences", () => {
|
|
74
|
+
expect(tryParseLooseJson('```json\n{"a":1}\n```')).toEqual({ a: 1 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("strips plain ``` fences", () => {
|
|
78
|
+
expect(tryParseLooseJson('```\n{"a":1}\n```')).toEqual({ a: 1 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recovers from prose preamble via brace-slice", () => {
|
|
82
|
+
expect(tryParseLooseJson('Here you go: {"a":1}')).toEqual({ a: 1 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("recovers from preamble + trailing chatter via brace-slice", () => {
|
|
86
|
+
expect(tryParseLooseJson('preamble\n{"a":1}\nthanks')).toEqual({ a: 1 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns null on genuine garbage", () => {
|
|
90
|
+
expect(tryParseLooseJson("not json at all")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("returns null on broken JSON inside fences", () => {
|
|
94
|
+
expect(tryParseLooseJson("```json\n{broken,}\n```")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("never throws — even on adversarial input", () => {
|
|
98
|
+
expect(() => tryParseLooseJson("{[}}}}")).not.toThrow();
|
|
99
|
+
expect(() => tryParseLooseJson("```")).not.toThrow();
|
|
100
|
+
expect(() => tryParseLooseJson("")).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("runMemoryRater — request shape", () => {
|
|
105
|
+
test("POSTs to OpenRouter chat-completions with the right model, strict json_schema response_format, and Authorization header", async () => {
|
|
106
|
+
let capturedUrl: string | URL | Request | undefined;
|
|
107
|
+
let capturedInit: RequestInit | undefined;
|
|
108
|
+
const fakeFetch: typeof fetch = async (url, init) => {
|
|
109
|
+
capturedUrl = url;
|
|
110
|
+
capturedInit = init;
|
|
111
|
+
return makeOpenRouterResponse(JSON.stringify({ summary: "ok", ratings: [] }));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = await runMemoryRater({
|
|
115
|
+
prompt: "test prompt",
|
|
116
|
+
apiKey: "test-api-key-123",
|
|
117
|
+
fetchImpl: fakeFetch,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.ok).toBe(true);
|
|
121
|
+
if (!result.ok) return;
|
|
122
|
+
|
|
123
|
+
expect(String(capturedUrl)).toBe("https://openrouter.ai/api/v1/chat/completions");
|
|
124
|
+
expect(capturedInit?.method).toBe("POST");
|
|
125
|
+
|
|
126
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
127
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
128
|
+
expect(headers.Authorization).toBe("Bearer test-api-key-123");
|
|
129
|
+
|
|
130
|
+
const body = JSON.parse(String(capturedInit?.body));
|
|
131
|
+
expect(body.model).toBe(DEFAULT_MEMORY_RATER_MODEL);
|
|
132
|
+
expect(body.messages).toEqual([{ role: "user", content: "test prompt" }]);
|
|
133
|
+
|
|
134
|
+
// OpenRouter strict json_schema mode — assert the wrapper shape.
|
|
135
|
+
expect(body.response_format.type).toBe("json_schema");
|
|
136
|
+
expect(body.response_format.json_schema.name).toBe(MEMORY_RATER_SCHEMA_NAME);
|
|
137
|
+
expect(body.response_format.json_schema.strict).toBe(true);
|
|
138
|
+
// Schema is the canonical one derived from SummaryWithRatingsSchema.
|
|
139
|
+
expect(body.response_format.json_schema.schema).toEqual(MEMORY_RATER_JSON_SCHEMA);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("MEMORY_RATER_JSON_SCHEMA reflects SummaryWithRatingsSchema (key shape only)", () => {
|
|
143
|
+
// Don't assert exact JSON Schema bytes — Zod's emitter can change with
|
|
144
|
+
// version bumps. Lock down the contract that matters for the OpenRouter
|
|
145
|
+
// call: top-level keys, required fields, additionalProperties: false,
|
|
146
|
+
// and the per-rating shape.
|
|
147
|
+
expect(MEMORY_RATER_JSON_SCHEMA.type).toBe("object");
|
|
148
|
+
expect(MEMORY_RATER_JSON_SCHEMA.additionalProperties).toBe(false);
|
|
149
|
+
expect(Array.isArray(MEMORY_RATER_JSON_SCHEMA.required)).toBe(true);
|
|
150
|
+
expect(MEMORY_RATER_JSON_SCHEMA.required as string[]).toEqual(
|
|
151
|
+
expect.arrayContaining(["summary", "ratings"]),
|
|
152
|
+
);
|
|
153
|
+
const props = MEMORY_RATER_JSON_SCHEMA.properties as Record<string, Record<string, unknown>>;
|
|
154
|
+
expect(props.summary.type).toBe("string");
|
|
155
|
+
expect(props.ratings.type).toBe("array");
|
|
156
|
+
const items = props.ratings.items as Record<string, unknown>;
|
|
157
|
+
expect(items.type).toBe("object");
|
|
158
|
+
expect(items.additionalProperties).toBe(false);
|
|
159
|
+
const itemProps = items.properties as Record<string, Record<string, unknown>>;
|
|
160
|
+
expect(itemProps.id.type).toBe("string");
|
|
161
|
+
expect(itemProps.score.type).toBe("number");
|
|
162
|
+
expect(itemProps.score.minimum).toBe(0);
|
|
163
|
+
expect(itemProps.score.maximum).toBe(1);
|
|
164
|
+
expect(itemProps.reasoning.type).toBe("string");
|
|
165
|
+
// referencesSource is optional → present in properties but not required.
|
|
166
|
+
expect(itemProps.referencesSource.type).toBe("string");
|
|
167
|
+
expect(items.required as string[]).toEqual(
|
|
168
|
+
expect.arrayContaining(["id", "score", "reasoning"]),
|
|
169
|
+
);
|
|
170
|
+
expect((items.required as string[]).includes("referencesSource")).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("schema does NOT carry a $schema metadata key (OpenRouter rejects extras at the root)", () => {
|
|
174
|
+
expect("$schema" in MEMORY_RATER_JSON_SCHEMA).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("explicit `model` opt overrides the env default", async () => {
|
|
178
|
+
let capturedBody: { model?: string } = {};
|
|
179
|
+
const fakeFetch: typeof fetch = async (_url, init) => {
|
|
180
|
+
capturedBody = JSON.parse(String(init?.body));
|
|
181
|
+
return makeOpenRouterResponse(JSON.stringify({ summary: "x", ratings: [] }));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const result = await runMemoryRater({
|
|
185
|
+
prompt: "p",
|
|
186
|
+
apiKey: "k",
|
|
187
|
+
model: "anthropic/claude-haiku-4.5",
|
|
188
|
+
fetchImpl: fakeFetch,
|
|
189
|
+
});
|
|
190
|
+
expect(result.ok).toBe(true);
|
|
191
|
+
if (result.ok) expect(result.model).toBe("anthropic/claude-haiku-4.5");
|
|
192
|
+
expect(capturedBody.model).toBe("anthropic/claude-haiku-4.5");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("MEMORY_RATER_MODEL env var changes the model when no opt is passed", async () => {
|
|
196
|
+
const prev = process.env.MEMORY_RATER_MODEL;
|
|
197
|
+
process.env.MEMORY_RATER_MODEL = "openai/gpt-5-mini";
|
|
198
|
+
try {
|
|
199
|
+
let capturedBody: { model?: string } = {};
|
|
200
|
+
const fakeFetch: typeof fetch = async (_url, init) => {
|
|
201
|
+
capturedBody = JSON.parse(String(init?.body));
|
|
202
|
+
return makeOpenRouterResponse(JSON.stringify({ summary: "x", ratings: [] }));
|
|
203
|
+
};
|
|
204
|
+
const result = await runMemoryRater({
|
|
205
|
+
prompt: "p",
|
|
206
|
+
apiKey: "k",
|
|
207
|
+
fetchImpl: fakeFetch,
|
|
208
|
+
});
|
|
209
|
+
expect(result.ok).toBe(true);
|
|
210
|
+
if (result.ok) expect(result.model).toBe("openai/gpt-5-mini");
|
|
211
|
+
expect(capturedBody.model).toBe("openai/gpt-5-mini");
|
|
212
|
+
} finally {
|
|
213
|
+
if (prev === undefined) delete process.env.MEMORY_RATER_MODEL;
|
|
214
|
+
else process.env.MEMORY_RATER_MODEL = prev;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("runMemoryRater — response handling", () => {
|
|
220
|
+
test("happy path — strict JSON content parses + validates", async () => {
|
|
221
|
+
const fakeFetch: typeof fetch = async () =>
|
|
222
|
+
makeOpenRouterResponse(
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
summary: "found two patterns",
|
|
225
|
+
ratings: [{ id: "mem-A", score: 0.9, reasoning: "directly answered" }],
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
230
|
+
expect(result.ok).toBe(true);
|
|
231
|
+
if (!result.ok) return;
|
|
232
|
+
expect(result.data.summary).toBe("found two patterns");
|
|
233
|
+
expect(result.data.ratings).toHaveLength(1);
|
|
234
|
+
expect(result.data.ratings[0]!.score).toBeCloseTo(0.9, 6);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("tolerant parser recovers from ```json fences (PR #447 regression)", async () => {
|
|
238
|
+
const inner = JSON.stringify({
|
|
239
|
+
summary: "fenced summary",
|
|
240
|
+
ratings: [{ id: "m", score: 0.7, reasoning: "useful" }],
|
|
241
|
+
});
|
|
242
|
+
const fakeFetch: typeof fetch = async () =>
|
|
243
|
+
makeOpenRouterResponse(`\`\`\`json\n${inner}\n\`\`\``);
|
|
244
|
+
|
|
245
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
246
|
+
expect(result.ok).toBe(true);
|
|
247
|
+
if (!result.ok) return;
|
|
248
|
+
expect(result.data.summary).toBe("fenced summary");
|
|
249
|
+
expect(result.data.ratings[0]!.score).toBeCloseTo(0.7, 6);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("tolerant parser recovers from prose preamble (PR #447 regression)", async () => {
|
|
253
|
+
const inner = JSON.stringify({
|
|
254
|
+
summary: "preambled summary",
|
|
255
|
+
ratings: [{ id: "m", score: 0, reasoning: "irrelevant" }],
|
|
256
|
+
});
|
|
257
|
+
const fakeFetch: typeof fetch = async () =>
|
|
258
|
+
makeOpenRouterResponse(`Here is the JSON:\n\n${inner}`);
|
|
259
|
+
|
|
260
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
261
|
+
expect(result.ok).toBe(true);
|
|
262
|
+
if (!result.ok) return;
|
|
263
|
+
expect(result.data.summary).toBe("preambled summary");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("schema-invalid content returns ok:false / reason:'schema'", async () => {
|
|
267
|
+
// score = 5 violates the [0, 1] range in SummaryWithRatingsSchema.
|
|
268
|
+
const fakeFetch: typeof fetch = async () =>
|
|
269
|
+
makeOpenRouterResponse(
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
summary: "x",
|
|
272
|
+
ratings: [{ id: "m", score: 5, reasoning: "bogus" }],
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
276
|
+
expect(result.ok).toBe(false);
|
|
277
|
+
if (!result.ok) expect(result.reason).toBe("schema");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("genuinely garbage content returns ok:false / reason:'parse'", async () => {
|
|
281
|
+
const fakeFetch: typeof fetch = async () =>
|
|
282
|
+
makeOpenRouterResponse("totally not JSON at all, just words");
|
|
283
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
284
|
+
expect(result.ok).toBe(false);
|
|
285
|
+
if (!result.ok) expect(result.reason).toBe("parse");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("HTTP 5xx returns ok:false / reason:'http_error' with status", async () => {
|
|
289
|
+
const fakeFetch: typeof fetch = async () => new Response("upstream blew up", { status: 502 });
|
|
290
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
291
|
+
expect(result.ok).toBe(false);
|
|
292
|
+
if (!result.ok) {
|
|
293
|
+
expect(result.reason).toBe("http_error");
|
|
294
|
+
expect(result.status).toBe(502);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("transport failure returns ok:false / reason:'transport'", async () => {
|
|
299
|
+
const fakeFetch: typeof fetch = async () => {
|
|
300
|
+
throw new Error("ECONNREFUSED");
|
|
301
|
+
};
|
|
302
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
303
|
+
expect(result.ok).toBe(false);
|
|
304
|
+
if (!result.ok) expect(result.reason).toBe("transport");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("missing choices[0].message.content returns ok:false / reason:'empty_content'", async () => {
|
|
308
|
+
const fakeFetch: typeof fetch = async () =>
|
|
309
|
+
new Response(JSON.stringify({ choices: [] }), {
|
|
310
|
+
status: 200,
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
});
|
|
313
|
+
const result = await runMemoryRater({ prompt: "p", apiKey: "k", fetchImpl: fakeFetch });
|
|
314
|
+
expect(result.ok).toBe(false);
|
|
315
|
+
if (!result.ok) expect(result.reason).toBe("empty_content");
|
|
316
|
+
});
|
|
317
|
+
});
|