@desplega.ai/agent-swarm 1.121.0 → 1.122.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 +3 -3
- package/dist/{actions-mwshtkf7.js → actions-k3bwtmgy.js} +5 -5
- package/dist/{app-skbty91x.js → app-knevny0f.js} +3 -3
- package/dist/{assistant-tg7dhfj8.js → assistant-as8087ft.js} +8 -8
- package/dist/{boot-reembed-ntjmeyh2.js → boot-reembed-1dcc1yyk.js} +3 -3
- package/dist/{boot-reembed-q5x0xpcp.js → boot-reembed-j305cpdr.js} +4 -4
- package/dist/{boot-scrub-logs-5gznsfbe.js → boot-scrub-logs-w9qkjzkm.js} +2 -2
- package/dist/{cli-s8bdcke2.js → cli-00yr0kxv.js} +1 -1
- package/dist/{cli-6s3ytc5x.js → cli-16dv125e.js} +27 -2
- package/dist/cli-3dpj7atn.js +11 -0
- package/dist/{cli-srjqn7fd.js → cli-3ptjc655.js} +1 -1
- package/dist/{cli-vg3zrw6h.js → cli-586x669g.js} +4 -4
- package/dist/{cli-5hpvd15x.js → cli-60q15ncp.js} +2 -2
- package/dist/{cli-ghq3v4c9.js → cli-7jb8mk4n.js} +1 -1
- package/dist/{cli-ky8esb8t.js → cli-8sv69q06.js} +3 -3
- package/dist/{cli-072t49hf.js → cli-8x745f40.js} +3 -3
- package/dist/{cli-3b1ayz83.js → cli-9fhxk91h.js} +5 -5
- package/dist/{cli-nf7sadev.js → cli-bxdb36d2.js} +2 -2
- package/dist/{cli-g9vva2qh.js → cli-fc1gvt9c.js} +1 -1
- package/dist/{cli-agdrxyex.js → cli-haeq79yf.js} +1 -1
- package/dist/{cli-7pzae66m.js → cli-hjq6c6vz.js} +1 -1
- package/dist/{cli-b2mqkg1h.js → cli-j89ph4c4.js} +2 -2
- package/dist/{cli-4pfweq4x.js → cli-kpfsabp2.js} +5 -5
- package/dist/{cli-95rbe11h.js → cli-m7dmk462.js} +12 -9
- package/dist/{cli-0df2g86x.js → cli-mjxnr12j.js} +1 -1
- package/dist/{cli-3fxqwfh3.js → cli-qf0qxzw5.js} +5 -5
- package/dist/{cli-vp9r4ap4.js → cli-qnmj37m5.js} +2 -2
- package/dist/{cli-tv7vekye.js → cli-tc8xk5zh.js} +1 -1
- package/dist/{cli-dmzv0986.js → cli-vh3h1grg.js} +10 -1
- package/dist/{cli-46etyprn.js → cli-w48hgc2y.js} +1 -1
- package/dist/{cli-avw9ev3x.js → cli-wmx631p8.js} +14 -11
- package/dist/{cli-gdpx70nc.js → cli-y3an9mk7.js} +19 -19
- package/dist/{cli-1728qh70.js → cli-z3hcrghs.js} +1 -1
- package/dist/{cli-p9w7kb3h.js → cli-zpa6gjwq.js} +1 -1
- package/dist/{cli-7pt9sc83.js → cli-zweca3q3.js} +3 -2
- package/dist/cli.js +10 -10
- package/dist/{codex-adapter-mmy741zd.js → codex-adapter-0kv2meed.js} +3 -2
- package/dist/{codex-session-runner-sazmx9zt.js → codex-session-runner-m1e1v4r0.js} +3 -2
- package/dist/{commands-cxxye5e4.js → commands-6j54dz62.js} +2 -2
- package/dist/{db-ab1954b7.js → db-gcnz2tqx.js} +2 -2
- package/dist/{handlers-fmfe2343.js → handlers-c43sm2h1.js} +8 -8
- package/dist/{hook-ygnw3cvt.js → hook-y1p25vm5.js} +3 -2
- package/dist/{http-kactcc0z.js → http-bsz1rze1.js} +35 -34
- package/dist/{index-gmzs6a2q.js → index-03zn2rz7.js} +10 -9
- package/dist/{index-g86j2yt5.js → index-7swcv6rv.js} +7 -7
- package/dist/{index-hmp82wn0.js → index-8m5gd1vs.js} +12 -11
- package/dist/{index-6erq0wvk.js → index-qyzfh62q.js} +11 -10
- package/dist/{keepalive-3cvt26dw.js → keepalive-69v93p45.js} +5 -5
- package/dist/{lead-ysf26cjr.js → lead-xn8kfchy.js} +23 -22
- package/dist/{maintenance-9npwptbb.js → maintenance-g2x7q13q.js} +4 -4
- package/dist/{oauth-refresh-sweep-0r2v76vw.js → oauth-refresh-sweep-j80szsyr.js} +4 -4
- package/dist/{onboard-ddb1674a.js → onboard-e3xcjt8b.js} +2 -2
- package/dist/{opencode-adapter-h6rkxwrk.js → opencode-adapter-s3nhtp9m.js} +4 -1
- package/dist/{otel-impl-b43bkq8e.js → otel-impl-24egvjaa.js} +1 -1
- package/dist/{pi-mono-adapter-ke789738.js → pi-mono-adapter-k3z6e5fn.js} +102 -4
- package/dist/{pricing-refresh-2rqf114z.js → pricing-refresh-01kg9gky.js} +4 -4
- package/dist/{rbac-roles-6vtafvzb.js → rbac-roles-f9bdj93y.js} +2 -2
- package/dist/{rbac-roles-5wkhc8xv.js → rbac-roles-jqfp0zza.js} +3 -3
- package/dist/{seed-pricing-23wrb873.js → seed-pricing-z2t7rank.js} +3 -3
- package/dist/{setup-6rny4fk6.js → setup-kw2ps002.js} +2 -2
- package/dist/{worker-yapewe14.js → worker-336w8930.js} +23 -22
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/memory/constants.ts +7 -6
- package/src/be/memory/graph-expansion.ts +3 -3
- package/src/be/memory/raters/llm-summarizer.ts +10 -2
- package/src/commands/provider-credentials.ts +2 -1
- package/src/http/memory.ts +2 -1
- package/src/providers/codex-adapter.ts +3 -0
- package/src/providers/opencode-adapter.ts +40 -0
- package/src/providers/pi-mono-adapter.ts +179 -1
- package/src/providers/pi-mono-extension.ts +9 -0
- package/src/tests/agent-avatar-resolution.test.ts +170 -0
- package/src/tests/memory-feature-flags.test.ts +45 -0
- package/src/tests/memory-rater-llm-summarizer.test.ts +24 -0
- package/src/tests/opencode-plugin.test.ts +43 -0
- package/src/tests/openrouter-base-url.test.ts +256 -0
- package/src/tests/pi-mono-adapter.test.ts +90 -0
- package/src/tools/memory-search.ts +2 -1
- package/src/utils/internal-ai/complete-structured.ts +19 -1
- package/src/utils/internal-ai/summarize-session.ts +7 -0
- package/src/utils/openrouter-base-url.ts +28 -0
- package/src/workflows/executors/raw-llm.ts +2 -1
- package/src/workflows/executors/validate.ts +2 -1
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "@earendil-works/pi-coding-agent";
|
|
29
29
|
import { type TSchema, Type } from "typebox";
|
|
30
30
|
import { classifyAwsSdkError } from "../utils/aws-error-classifier";
|
|
31
|
+
import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
|
|
31
32
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
32
33
|
import { readPkgVersion } from "./harness-version";
|
|
33
34
|
import { createSwarmHooksExtension } from "./pi-mono-extension";
|
|
@@ -356,6 +357,172 @@ export async function createPiRuntimeAuth(
|
|
|
356
357
|
return modelRuntime;
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Sidecar marker recording the gateway `baseUrl` WE wrote into models.json
|
|
362
|
+
* (and any user value we displaced). Lets `ensureOpenRouterModelsOverride`
|
|
363
|
+
* revert precisely when the env returns to default, without ever touching a
|
|
364
|
+
* hand-authored override it didn't create. Lives next to models.json; NOT
|
|
365
|
+
* part of pi's config surface.
|
|
366
|
+
*/
|
|
367
|
+
const OPENROUTER_OVERRIDE_MARKER = ".agent-swarm-openrouter-override.json";
|
|
368
|
+
|
|
369
|
+
interface OpenRouterOverrideMarker {
|
|
370
|
+
baseUrl: string;
|
|
371
|
+
/** User-authored baseUrl we displaced, restored on revert. */
|
|
372
|
+
previous?: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readJsonFile(path: string): Promise<Record<string, unknown> | undefined> {
|
|
376
|
+
try {
|
|
377
|
+
const file = Bun.file(path);
|
|
378
|
+
if (!(await file.exists())) return undefined;
|
|
379
|
+
const parsed: unknown = JSON.parse(await file.text());
|
|
380
|
+
return typeof parsed === "object" && parsed !== null
|
|
381
|
+
? (parsed as Record<string, unknown>)
|
|
382
|
+
: undefined;
|
|
383
|
+
} catch {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Materialize (or revert) the OpenRouter gateway override in
|
|
390
|
+
* `<agentDir>/models.json` so pi-coding-agent's `ModelRuntime` (which loads
|
|
391
|
+
* that file at create time) composes every built-in OpenRouter model with
|
|
392
|
+
* the gateway `baseUrl` — see `src/utils/openrouter-base-url.ts` for the
|
|
393
|
+
* env contract.
|
|
394
|
+
*
|
|
395
|
+
* Gateway set (non-default): merge-preserving write — existing provider
|
|
396
|
+
* entries and other openrouter keys are kept, only
|
|
397
|
+
* `providers.openrouter.baseUrl` is set; a displaced user value is recorded
|
|
398
|
+
* in a sidecar marker. A corrupt existing file is overwritten with just the
|
|
399
|
+
* override — for gateway deployments, guaranteed rerouting wins over
|
|
400
|
+
* preserving unparseable config.
|
|
401
|
+
*
|
|
402
|
+
* Gateway unset/default: reverts a PREVIOUSLY WRITTEN override (marker
|
|
403
|
+
* present and models.json still carries the marker's value) — restoring the
|
|
404
|
+
* displaced user baseUrl or deleting the key, dropping now-empty objects,
|
|
405
|
+
* and removing models.json entirely if the override was all it contained.
|
|
406
|
+
* The env value in a shared worker isn't per-task in practice, but reverts
|
|
407
|
+
* make rollback = "unset the env" and keep repo-scoped/self-hosted setups
|
|
408
|
+
* from inheriting a stale gateway (PR #1010 review). Without a marker the
|
|
409
|
+
* function never touches models.json, so hand-authored overrides survive.
|
|
410
|
+
*/
|
|
411
|
+
export async function ensureOpenRouterModelsOverride(
|
|
412
|
+
agentDir: string,
|
|
413
|
+
env: Record<string, string | undefined> = process.env,
|
|
414
|
+
): Promise<void> {
|
|
415
|
+
const baseUrl = getOpenRouterBaseUrl(env as NodeJS.ProcessEnv);
|
|
416
|
+
const modelsPath = join(agentDir, "models.json");
|
|
417
|
+
const markerPath = join(agentDir, OPENROUTER_OVERRIDE_MARKER);
|
|
418
|
+
|
|
419
|
+
if (baseUrl === DEFAULT_OPENROUTER_BASE_URL) {
|
|
420
|
+
await revertOpenRouterModelsOverride(modelsPath, markerPath);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let existing: Record<string, unknown> = {};
|
|
425
|
+
try {
|
|
426
|
+
const file = Bun.file(modelsPath);
|
|
427
|
+
if (await file.exists()) {
|
|
428
|
+
existing = JSON.parse(await file.text()) as Record<string, unknown>;
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.warn(
|
|
432
|
+
`[pi-mono] ${modelsPath} is unreadable/invalid (${err instanceof Error ? err.message : err}); rewriting with the OpenRouter gateway override`,
|
|
433
|
+
);
|
|
434
|
+
existing = {};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const providers =
|
|
438
|
+
typeof existing.providers === "object" && existing.providers !== null
|
|
439
|
+
? (existing.providers as Record<string, unknown>)
|
|
440
|
+
: {};
|
|
441
|
+
const openrouter =
|
|
442
|
+
typeof providers.openrouter === "object" && providers.openrouter !== null
|
|
443
|
+
? (providers.openrouter as Record<string, unknown>)
|
|
444
|
+
: {};
|
|
445
|
+
|
|
446
|
+
// Record what we displace, once — re-runs with the marker already present
|
|
447
|
+
// keep the ORIGINAL displaced value (the current baseUrl is then ours).
|
|
448
|
+
const priorMarker = (await readJsonFile(markerPath)) as OpenRouterOverrideMarker | undefined;
|
|
449
|
+
const currentBaseUrl = typeof openrouter.baseUrl === "string" ? openrouter.baseUrl : undefined;
|
|
450
|
+
const previous = priorMarker
|
|
451
|
+
? priorMarker.previous
|
|
452
|
+
: currentBaseUrl !== baseUrl
|
|
453
|
+
? currentBaseUrl
|
|
454
|
+
: undefined;
|
|
455
|
+
|
|
456
|
+
const next = {
|
|
457
|
+
...existing,
|
|
458
|
+
providers: {
|
|
459
|
+
...providers,
|
|
460
|
+
openrouter: { ...openrouter, baseUrl },
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
await Bun.write(modelsPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
464
|
+
const marker: OpenRouterOverrideMarker = {
|
|
465
|
+
baseUrl,
|
|
466
|
+
...(previous !== undefined ? { previous } : {}),
|
|
467
|
+
};
|
|
468
|
+
await Bun.write(markerPath, `${JSON.stringify(marker, null, 2)}\n`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Undo a marker-recorded gateway override. Only acts when the marker exists
|
|
473
|
+
* AND models.json's `providers.openrouter.baseUrl` still equals the value
|
|
474
|
+
* the marker says we wrote — anything else means the user edited the file
|
|
475
|
+
* since, and we leave it alone (the stale marker is dropped either way).
|
|
476
|
+
*/
|
|
477
|
+
async function revertOpenRouterModelsOverride(
|
|
478
|
+
modelsPath: string,
|
|
479
|
+
markerPath: string,
|
|
480
|
+
): Promise<void> {
|
|
481
|
+
const marker = (await readJsonFile(markerPath)) as OpenRouterOverrideMarker | undefined;
|
|
482
|
+
if (!marker) return;
|
|
483
|
+
const removeMarker = () => Bun.file(markerPath).delete();
|
|
484
|
+
|
|
485
|
+
const existing = await readJsonFile(modelsPath);
|
|
486
|
+
if (!existing) {
|
|
487
|
+
await removeMarker();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const providers =
|
|
491
|
+
typeof existing.providers === "object" && existing.providers !== null
|
|
492
|
+
? { ...(existing.providers as Record<string, unknown>) }
|
|
493
|
+
: undefined;
|
|
494
|
+
const openrouter =
|
|
495
|
+
providers && typeof providers.openrouter === "object" && providers.openrouter !== null
|
|
496
|
+
? { ...(providers.openrouter as Record<string, unknown>) }
|
|
497
|
+
: undefined;
|
|
498
|
+
if (!providers || !openrouter || openrouter.baseUrl !== marker.baseUrl) {
|
|
499
|
+
await removeMarker();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (marker.previous !== undefined) {
|
|
504
|
+
openrouter.baseUrl = marker.previous;
|
|
505
|
+
} else {
|
|
506
|
+
delete openrouter.baseUrl;
|
|
507
|
+
}
|
|
508
|
+
if (Object.keys(openrouter).length === 0) {
|
|
509
|
+
delete providers.openrouter;
|
|
510
|
+
} else {
|
|
511
|
+
providers.openrouter = openrouter;
|
|
512
|
+
}
|
|
513
|
+
const next: Record<string, unknown> = { ...existing, providers };
|
|
514
|
+
if (Object.keys(providers).length === 0) {
|
|
515
|
+
delete next.providers;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (Object.keys(next).length === 0) {
|
|
519
|
+
await Bun.file(modelsPath).delete();
|
|
520
|
+
} else {
|
|
521
|
+
await Bun.write(modelsPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
522
|
+
}
|
|
523
|
+
await removeMarker();
|
|
524
|
+
}
|
|
525
|
+
|
|
359
526
|
/**
|
|
360
527
|
* Resolve a model string to a pi-ai Model object.
|
|
361
528
|
*
|
|
@@ -960,8 +1127,18 @@ export class PiMonoAdapter implements ProviderAdapter {
|
|
|
960
1127
|
const sessionEnv = config.env ?? process.env;
|
|
961
1128
|
|
|
962
1129
|
// 3. Resolve model
|
|
963
|
-
|
|
1130
|
+
// Write the gateway override BEFORE ModelRuntime.create — the runtime
|
|
1131
|
+
// loads `<agentDir>/models.json` exactly once at creation.
|
|
1132
|
+
await ensureOpenRouterModelsOverride(getAgentDir(), sessionEnv);
|
|
1133
|
+
const builtinModel = resolveModel(config.model, sessionEnv);
|
|
964
1134
|
const modelRuntime = await createPiRuntimeAuth(sessionEnv);
|
|
1135
|
+
// models.json overrides (e.g. the OpenRouter gateway baseUrl) only apply
|
|
1136
|
+
// to models resolved through the runtime's composed store; the builtin
|
|
1137
|
+
// catalog object from `resolveModel` carries the hardcoded upstream URL
|
|
1138
|
+
// and would be used verbatim by the session.
|
|
1139
|
+
const model = builtinModel
|
|
1140
|
+
? (modelRuntime.getModel(builtinModel.provider, builtinModel.id) ?? builtinModel)
|
|
1141
|
+
: builtinModel;
|
|
965
1142
|
|
|
966
1143
|
// 4. Create swarm hooks extension
|
|
967
1144
|
const swarmExtension = createSwarmHooksExtension({
|
|
@@ -970,6 +1147,7 @@ export class PiMonoAdapter implements ProviderAdapter {
|
|
|
970
1147
|
agentId: config.agentId,
|
|
971
1148
|
taskId: config.taskId,
|
|
972
1149
|
isLead: config.role === "lead",
|
|
1150
|
+
env: sessionEnv,
|
|
973
1151
|
});
|
|
974
1152
|
|
|
975
1153
|
// 5. Create resource loader with system prompt + extension
|
|
@@ -17,6 +17,12 @@ export interface SwarmHooksConfig {
|
|
|
17
17
|
agentId: string;
|
|
18
18
|
taskId: string;
|
|
19
19
|
isLead: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Per-task resolved env (`ProviderSessionConfig.env`). Forwarded to the
|
|
22
|
+
* session-summary path so swarm_config overlays (rotated credentials,
|
|
23
|
+
* `OPENROUTER_BASE_URL`) apply — they never reach `process.env`.
|
|
24
|
+
*/
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** Standard headers for swarm API requests */
|
|
@@ -368,6 +374,9 @@ export async function summarizeSessionForPi(
|
|
|
368
374
|
},
|
|
369
375
|
apiUrl: config.apiUrl,
|
|
370
376
|
apiKey: config.apiKey,
|
|
377
|
+
// Per-task resolved env — swarm_config overlays (e.g.
|
|
378
|
+
// OPENROUTER_BASE_URL) never reach process.env.
|
|
379
|
+
env: config.env,
|
|
371
380
|
});
|
|
372
381
|
// null = no auth resolved or wrapper exhausted retries (already logged inside)
|
|
373
382
|
if (!result) return;
|
|
@@ -3,7 +3,10 @@ import { getAgentColorToken, resolveAgentColor } from "../../apps/ui/src/lib/age
|
|
|
3
3
|
import {
|
|
4
4
|
AVATAR_ICON_CATALOG,
|
|
5
5
|
getAgentIcon,
|
|
6
|
+
MAX_AVATAR_ICON_SEARCH_RESULTS,
|
|
6
7
|
resolveAgentIcon,
|
|
8
|
+
searchAvatarIcons,
|
|
9
|
+
WORKER_ICONS,
|
|
7
10
|
} from "../../apps/ui/src/lib/agent-icon";
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -15,6 +18,40 @@ import {
|
|
|
15
18
|
*/
|
|
16
19
|
describe("agent avatar resolution", () => {
|
|
17
20
|
describe("getAgentIcon (deterministic fallback)", () => {
|
|
21
|
+
test("keeps the 30-entry worker pool and its established order", () => {
|
|
22
|
+
expect(WORKER_ICONS).toEqual([
|
|
23
|
+
AVATAR_ICON_CATALOG.bot,
|
|
24
|
+
AVATAR_ICON_CATALOG.cat,
|
|
25
|
+
AVATAR_ICON_CATALOG.dog,
|
|
26
|
+
AVATAR_ICON_CATALOG.bird,
|
|
27
|
+
AVATAR_ICON_CATALOG.fish,
|
|
28
|
+
AVATAR_ICON_CATALOG.bug,
|
|
29
|
+
AVATAR_ICON_CATALOG.snail,
|
|
30
|
+
AVATAR_ICON_CATALOG.turtle,
|
|
31
|
+
AVATAR_ICON_CATALOG.squirrel,
|
|
32
|
+
AVATAR_ICON_CATALOG.cherry,
|
|
33
|
+
AVATAR_ICON_CATALOG.apple,
|
|
34
|
+
AVATAR_ICON_CATALOG.carrot,
|
|
35
|
+
AVATAR_ICON_CATALOG.leaf,
|
|
36
|
+
AVATAR_ICON_CATALOG.sprout,
|
|
37
|
+
AVATAR_ICON_CATALOG["tree-deciduous"],
|
|
38
|
+
AVATAR_ICON_CATALOG.flower,
|
|
39
|
+
AVATAR_ICON_CATALOG.mountain,
|
|
40
|
+
AVATAR_ICON_CATALOG.sun,
|
|
41
|
+
AVATAR_ICON_CATALOG.moon,
|
|
42
|
+
AVATAR_ICON_CATALOG.cloud,
|
|
43
|
+
AVATAR_ICON_CATALOG.snowflake,
|
|
44
|
+
AVATAR_ICON_CATALOG.sparkles,
|
|
45
|
+
AVATAR_ICON_CATALOG.star,
|
|
46
|
+
AVATAR_ICON_CATALOG.rocket,
|
|
47
|
+
AVATAR_ICON_CATALOG.plane,
|
|
48
|
+
AVATAR_ICON_CATALOG.anchor,
|
|
49
|
+
AVATAR_ICON_CATALOG.compass,
|
|
50
|
+
AVATAR_ICON_CATALOG.telescope,
|
|
51
|
+
AVATAR_ICON_CATALOG.atom,
|
|
52
|
+
AVATAR_ICON_CATALOG.crown,
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
18
55
|
test("lead always resolves to Crown, regardless of id", () => {
|
|
19
56
|
expect(getAgentIcon({ agentId: "any-id", isLead: true })).toBe(AVATAR_ICON_CATALOG.crown);
|
|
20
57
|
expect(getAgentIcon({ role: "lead", agentId: "any-id" })).toBe(AVATAR_ICON_CATALOG.crown);
|
|
@@ -29,6 +66,11 @@ describe("agent avatar resolution", () => {
|
|
|
29
66
|
expect(first).toBe(second);
|
|
30
67
|
});
|
|
31
68
|
|
|
69
|
+
test("keeps fixed seeds on their established fallback icons", () => {
|
|
70
|
+
expect(getAgentIcon({ agentId: "fixed-seed-alpha" })).toBe(AVATAR_ICON_CATALOG.snail);
|
|
71
|
+
expect(getAgentIcon({ agentId: "fixed-seed-beta" })).toBe(AVATAR_ICON_CATALOG.moon);
|
|
72
|
+
});
|
|
73
|
+
|
|
32
74
|
test("different agentIds can resolve to different icons", () => {
|
|
33
75
|
// Not a strict guarantee for every pair (hash collisions are possible),
|
|
34
76
|
// but across a spread of ids we should see more than one icon.
|
|
@@ -44,6 +86,134 @@ describe("agent avatar resolution", () => {
|
|
|
44
86
|
});
|
|
45
87
|
});
|
|
46
88
|
|
|
89
|
+
describe("avatar icon picker search", () => {
|
|
90
|
+
test("returns the exact familiar shortlist for an empty query, not just its length", () => {
|
|
91
|
+
// Explicit contents (not just `.toHaveLength(64)`) so this can't silently
|
|
92
|
+
// drift to a different 64 if AVATAR_ICON_CATALOG is ever reordered.
|
|
93
|
+
const results = searchAvatarIcons("");
|
|
94
|
+
expect(results.iconResults).toEqual([
|
|
95
|
+
"bot",
|
|
96
|
+
"cat",
|
|
97
|
+
"dog",
|
|
98
|
+
"bird",
|
|
99
|
+
"fish",
|
|
100
|
+
"bug",
|
|
101
|
+
"snail",
|
|
102
|
+
"turtle",
|
|
103
|
+
"squirrel",
|
|
104
|
+
"rabbit",
|
|
105
|
+
"cherry",
|
|
106
|
+
"apple",
|
|
107
|
+
"carrot",
|
|
108
|
+
"leaf",
|
|
109
|
+
"sprout",
|
|
110
|
+
"tree-deciduous",
|
|
111
|
+
"flower",
|
|
112
|
+
"mountain",
|
|
113
|
+
"sun",
|
|
114
|
+
"moon",
|
|
115
|
+
"cloud",
|
|
116
|
+
"snowflake",
|
|
117
|
+
"sparkles",
|
|
118
|
+
"star",
|
|
119
|
+
"rocket",
|
|
120
|
+
"plane",
|
|
121
|
+
"anchor",
|
|
122
|
+
"compass",
|
|
123
|
+
"telescope",
|
|
124
|
+
"atom",
|
|
125
|
+
"crown",
|
|
126
|
+
"award",
|
|
127
|
+
"book",
|
|
128
|
+
"box",
|
|
129
|
+
"briefcase",
|
|
130
|
+
"camera",
|
|
131
|
+
"coffee",
|
|
132
|
+
"diamond",
|
|
133
|
+
"droplet",
|
|
134
|
+
"feather",
|
|
135
|
+
"flag",
|
|
136
|
+
"flame",
|
|
137
|
+
"gem",
|
|
138
|
+
"ghost",
|
|
139
|
+
"gift",
|
|
140
|
+
"globe",
|
|
141
|
+
"heart",
|
|
142
|
+
"key",
|
|
143
|
+
"lock",
|
|
144
|
+
"map",
|
|
145
|
+
"music",
|
|
146
|
+
"package",
|
|
147
|
+
"palette",
|
|
148
|
+
"pizza",
|
|
149
|
+
"puzzle",
|
|
150
|
+
"shield",
|
|
151
|
+
"skull",
|
|
152
|
+
"sword",
|
|
153
|
+
"target",
|
|
154
|
+
"trophy",
|
|
155
|
+
"umbrella",
|
|
156
|
+
"wand",
|
|
157
|
+
"zap",
|
|
158
|
+
"wrench",
|
|
159
|
+
]);
|
|
160
|
+
expect(results.totalMatches).toBe(64);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("normalizes case, spaces, and hyphens", () => {
|
|
164
|
+
expect(searchAvatarIcons("TREE DECIDUOUS").iconResults).toContain("tree-deciduous");
|
|
165
|
+
expect(searchAvatarIcons("tree-deciduous").iconResults).toContain("tree-deciduous");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("returns no choices for an unmatched query", () => {
|
|
169
|
+
expect(searchAvatarIcons("not-an-icon-at-all")).toEqual({ iconResults: [], totalMatches: 0 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("caps broad searches at 100 choices while retaining the match total", () => {
|
|
173
|
+
const results = searchAvatarIcons("a");
|
|
174
|
+
expect(results.totalMatches).toBeGreaterThan(MAX_AVATAR_ICON_SEARCH_RESULTS);
|
|
175
|
+
expect(results.iconResults).toHaveLength(MAX_AVATAR_ICON_SEARCH_RESULTS);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the catalog has no duplicate-glyph deprecated-alias pairs", () => {
|
|
179
|
+
// Every icon component in the catalog should be unique — a deprecated
|
|
180
|
+
// lucide alias re-exports the SAME component as its canonical name, so
|
|
181
|
+
// two catalog keys pointing at the same component is exactly that bug.
|
|
182
|
+
const seen = new Map<unknown, string>();
|
|
183
|
+
const dupes: Array<{ key: string; dupOf: string }> = [];
|
|
184
|
+
for (const [key, icon] of Object.entries(AVATAR_ICON_CATALOG)) {
|
|
185
|
+
const existing = seen.get(icon);
|
|
186
|
+
if (existing) dupes.push({ key, dupOf: existing });
|
|
187
|
+
else seen.set(icon, key);
|
|
188
|
+
}
|
|
189
|
+
expect(dupes).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("covers previously-unreachable d-z avatar-worthy searches", () => {
|
|
193
|
+
// Round-3 fix: the catalog used to be an alphabetical head-slice
|
|
194
|
+
// (a/b/c only), so none of these ever returned a result.
|
|
195
|
+
for (const query of [
|
|
196
|
+
"wrench",
|
|
197
|
+
"gamepad",
|
|
198
|
+
"sword",
|
|
199
|
+
"rocket",
|
|
200
|
+
"crown",
|
|
201
|
+
"heart",
|
|
202
|
+
"star",
|
|
203
|
+
"zap",
|
|
204
|
+
]) {
|
|
205
|
+
expect(searchAvatarIcons(query).totalMatches).toBeGreaterThan(0);
|
|
206
|
+
}
|
|
207
|
+
for (const query of ["ghost", "cat", "dog", "pizza", "music"]) {
|
|
208
|
+
expect(searchAvatarIcons(query).totalMatches).toBeGreaterThan(0);
|
|
209
|
+
}
|
|
210
|
+
// Spot-check letters that were entirely absent before this round.
|
|
211
|
+
for (const query of ["guitar", "octagon", "volleyball", "wheat"]) {
|
|
212
|
+
expect(searchAvatarIcons(query).totalMatches).toBeGreaterThan(0);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
47
217
|
describe("resolveAgentIcon (custom avatar overrides deterministic fallback)", () => {
|
|
48
218
|
test("a known catalog icon wins over the deterministic fallback", () => {
|
|
49
219
|
const avatar = { type: "lucide" as const, icon: "trophy" };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { isGraphExpansionEnabled, isHybridSearchEnabled } from "../be/memory/constants";
|
|
3
|
+
|
|
4
|
+
const previousHybrid = process.env.MEMORY_HYBRID_SEARCH;
|
|
5
|
+
const previousGraph = process.env.MEMORY_GRAPH_EXPANSION;
|
|
6
|
+
|
|
7
|
+
afterAll(() => {
|
|
8
|
+
if (previousHybrid === undefined) delete process.env.MEMORY_HYBRID_SEARCH;
|
|
9
|
+
else process.env.MEMORY_HYBRID_SEARCH = previousHybrid;
|
|
10
|
+
|
|
11
|
+
if (previousGraph === undefined) delete process.env.MEMORY_GRAPH_EXPANSION;
|
|
12
|
+
else process.env.MEMORY_GRAPH_EXPANSION = previousGraph;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("memory retrieval feature flags", () => {
|
|
16
|
+
test("hybrid search defaults on and keeps explicit enable/disable overrides", () => {
|
|
17
|
+
delete process.env.MEMORY_HYBRID_SEARCH;
|
|
18
|
+
expect(isHybridSearchEnabled()).toBe(true);
|
|
19
|
+
|
|
20
|
+
for (const enabled of ["", " ", "\t", "1", "true", "TRUE"]) {
|
|
21
|
+
process.env.MEMORY_HYBRID_SEARCH = enabled;
|
|
22
|
+
expect(isHybridSearchEnabled()).toBe(true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const disabled of ["0", "false", "FALSE", " 0 ", " false "]) {
|
|
26
|
+
process.env.MEMORY_HYBRID_SEARCH = disabled;
|
|
27
|
+
expect(isHybridSearchEnabled()).toBe(false);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("graph expansion defaults on and keeps explicit enable/disable overrides", () => {
|
|
32
|
+
delete process.env.MEMORY_GRAPH_EXPANSION;
|
|
33
|
+
expect(isGraphExpansionEnabled()).toBe(true);
|
|
34
|
+
|
|
35
|
+
for (const enabled of ["", " ", "\t", "1", "true", "TRUE"]) {
|
|
36
|
+
process.env.MEMORY_GRAPH_EXPANSION = enabled;
|
|
37
|
+
expect(isGraphExpansionEnabled()).toBe(true);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const disabled of ["0", "false", "FALSE", " 0 ", " false "]) {
|
|
41
|
+
process.env.MEMORY_GRAPH_EXPANSION = disabled;
|
|
42
|
+
expect(isGraphExpansionEnabled()).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -139,6 +139,30 @@ describe("runMemoryRater — request shape", () => {
|
|
|
139
139
|
expect(body.response_format.json_schema.schema).toEqual(MEMORY_RATER_JSON_SCHEMA);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
test("OPENROUTER_BASE_URL reroutes the chat-completions call through the gateway", async () => {
|
|
143
|
+
const origBaseUrl = process.env.OPENROUTER_BASE_URL;
|
|
144
|
+
process.env.OPENROUTER_BASE_URL = "https://control-plane.example/proxy/v1/";
|
|
145
|
+
try {
|
|
146
|
+
let capturedUrl: string | URL | Request | undefined;
|
|
147
|
+
const fakeFetch: typeof fetch = async (url) => {
|
|
148
|
+
capturedUrl = url;
|
|
149
|
+
return makeOpenRouterResponse(JSON.stringify({ summary: "ok", ratings: [] }));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const result = await runMemoryRater({
|
|
153
|
+
prompt: "test prompt",
|
|
154
|
+
apiKey: "test-api-key-123",
|
|
155
|
+
fetchImpl: fakeFetch,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.ok).toBe(true);
|
|
159
|
+
expect(String(capturedUrl)).toBe("https://control-plane.example/proxy/v1/chat/completions");
|
|
160
|
+
} finally {
|
|
161
|
+
if (origBaseUrl === undefined) delete process.env.OPENROUTER_BASE_URL;
|
|
162
|
+
else process.env.OPENROUTER_BASE_URL = origBaseUrl;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
142
166
|
test("MEMORY_RATER_JSON_SCHEMA reflects SummaryWithRatingsSchema (key shape only)", () => {
|
|
143
167
|
// Don't assert exact JSON Schema bytes — Zod's emitter can change with
|
|
144
168
|
// version bumps. Lock down the contract that matters for the OpenRouter
|
|
@@ -21,6 +21,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
21
21
|
import type { Message, Part } from "@opencode-ai/sdk";
|
|
22
22
|
import {
|
|
23
23
|
flattenOpencodeTranscript,
|
|
24
|
+
runSummaryLlm,
|
|
24
25
|
type SummarizeSessionForOpencodeDeps,
|
|
25
26
|
type SwarmConfig,
|
|
26
27
|
summarizeSessionForOpencode,
|
|
@@ -494,3 +495,45 @@ describe("summarizeSessionForOpencode", () => {
|
|
|
494
495
|
expect(postRatingsArgs!.taskId).toBe("task-oc-1");
|
|
495
496
|
});
|
|
496
497
|
});
|
|
498
|
+
|
|
499
|
+
// ── OPENROUTER_BASE_URL gateway routing (vendored resolver in summarize.ts) ──
|
|
500
|
+
|
|
501
|
+
describe("runSummaryLlm — OPENROUTER_BASE_URL gateway", () => {
|
|
502
|
+
const summaryBody = {
|
|
503
|
+
choices: [{ message: { content: JSON.stringify({ summary: "ok", ratings: [] }) } }],
|
|
504
|
+
};
|
|
505
|
+
const openrouterCred = {
|
|
506
|
+
kind: "openrouter" as const,
|
|
507
|
+
apiKey: "sk-or-test",
|
|
508
|
+
modelDefault: "openrouter/google/gemini-3-flash-preview",
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
afterEach(() => {
|
|
512
|
+
delete process.env.OPENROUTER_BASE_URL;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("defaults to openrouter.ai when the env is unset", async () => {
|
|
516
|
+
fetchHandler = async () => ({
|
|
517
|
+
ok: true,
|
|
518
|
+
status: 200,
|
|
519
|
+
text: async () => "",
|
|
520
|
+
json: async () => summaryBody,
|
|
521
|
+
});
|
|
522
|
+
const result = await runSummaryLlm(openrouterCred, "sys", "user");
|
|
523
|
+
expect(result).not.toBeNull();
|
|
524
|
+
expect(fetchCalls[0]?.url).toBe("https://openrouter.ai/api/v1/chat/completions");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("reroutes through the gateway when OPENROUTER_BASE_URL is set", async () => {
|
|
528
|
+
process.env.OPENROUTER_BASE_URL = "https://control-plane.example/proxy/v1/";
|
|
529
|
+
fetchHandler = async () => ({
|
|
530
|
+
ok: true,
|
|
531
|
+
status: 200,
|
|
532
|
+
text: async () => "",
|
|
533
|
+
json: async () => summaryBody,
|
|
534
|
+
});
|
|
535
|
+
const result = await runSummaryLlm(openrouterCred, "sys", "user");
|
|
536
|
+
expect(result).not.toBeNull();
|
|
537
|
+
expect(fetchCalls[0]?.url).toBe("https://control-plane.example/proxy/v1/chat/completions");
|
|
538
|
+
});
|
|
539
|
+
});
|