@desplega.ai/agent-swarm 1.92.2 → 1.94.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.
Files changed (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -577,11 +577,8 @@ describe("Approval Requests", () => {
577
577
  });
578
578
 
579
579
  expect(result.status).toBe("success");
580
- // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
581
580
  expect((result as any).async).toBe(true);
582
- // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
583
581
  expect((result as any).waitFor).toBe("approval.resolved");
584
- // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
585
582
  expect((result as any).correlationId).toBeTruthy();
586
583
 
587
584
  // Verify the request was created in DB
@@ -616,9 +613,7 @@ describe("Approval Requests", () => {
616
613
  });
617
614
 
618
615
  expect(result.status).toBe("success");
619
- // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
620
616
  expect((result as any).async).toBe(true);
621
- // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
622
617
  expect((result as any).correlationId).toBe(existingId);
623
618
  });
624
619
 
@@ -650,7 +645,6 @@ describe("Approval Requests", () => {
650
645
  });
651
646
 
652
647
  expect(result.status).toBe("success");
653
- // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
654
648
  expect((result as any).async).toBeUndefined();
655
649
  expect(result.output).toBeDefined();
656
650
  expect(result.output!.requestId).toBe(existingId);
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Unit tests for `classifyAwsSdkError` in `src/utils/aws-error-classifier.ts`.
3
+ *
4
+ * Exercises all four error categories and the no-match path.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { classifyAwsSdkError } from "../utils/aws-error-classifier";
9
+
10
+ describe("classifyAwsSdkError — aws-auth", () => {
11
+ test("ExpiredTokenException", () => {
12
+ const r = classifyAwsSdkError(
13
+ "ExpiredTokenException: The security token included in the request is expired",
14
+ );
15
+ expect(r).not.toBeNull();
16
+ expect(r!.category).toBe("aws-auth");
17
+ expect(r!.message).toContain("aws sso login");
18
+ });
19
+
20
+ test("ExpiredToken (without Exception suffix)", () => {
21
+ const r = classifyAwsSdkError("ExpiredToken: token expired");
22
+ expect(r?.category).toBe("aws-auth");
23
+ });
24
+
25
+ test("CredentialsProviderError", () => {
26
+ const r = classifyAwsSdkError("CredentialsProviderError: Could not load credentials");
27
+ expect(r?.category).toBe("aws-auth");
28
+ });
29
+
30
+ test("Unable to locate credentials", () => {
31
+ const r = classifyAwsSdkError(
32
+ 'Unable to locate credentials. You can configure credentials by running "aws configure".',
33
+ );
34
+ expect(r?.category).toBe("aws-auth");
35
+ });
36
+
37
+ test("security token ... expired (lower-case)", () => {
38
+ const r = classifyAwsSdkError("The security token included in the request is expired");
39
+ expect(r?.category).toBe("aws-auth");
40
+ });
41
+
42
+ test("InvalidSignatureException", () => {
43
+ const r = classifyAwsSdkError(
44
+ "InvalidSignatureException: The request signature we calculated does not match the signature you provided",
45
+ );
46
+ expect(r?.category).toBe("aws-auth");
47
+ });
48
+
49
+ test("UnrecognizedClientException", () => {
50
+ const r = classifyAwsSdkError(
51
+ "UnrecognizedClientException: The security token included in the request is invalid",
52
+ );
53
+ expect(r?.category).toBe("aws-auth");
54
+ });
55
+ });
56
+
57
+ describe("classifyAwsSdkError — aws-throttle", () => {
58
+ test("ThrottlingException", () => {
59
+ const r = classifyAwsSdkError("ThrottlingException: Rate exceeded");
60
+ expect(r?.category).toBe("aws-throttle");
61
+ expect(r!.message).toContain("quota");
62
+ });
63
+
64
+ test("TooManyRequestsException", () => {
65
+ const r = classifyAwsSdkError("TooManyRequestsException: Too many requests");
66
+ expect(r?.category).toBe("aws-throttle");
67
+ });
68
+
69
+ test("ServiceQuotaExceededException", () => {
70
+ const r = classifyAwsSdkError(
71
+ "ServiceQuotaExceededException: You have exceeded your request quota for this service",
72
+ );
73
+ expect(r?.category).toBe("aws-throttle");
74
+ });
75
+
76
+ test("Rate exceeded (standalone phrase)", () => {
77
+ const r = classifyAwsSdkError("Rate exceeded. Reduce your request rate.");
78
+ expect(r?.category).toBe("aws-throttle");
79
+ });
80
+ });
81
+
82
+ describe("classifyAwsSdkError — aws-access", () => {
83
+ test("AccessDeniedException with bedrock:InvokeModel", () => {
84
+ const r = classifyAwsSdkError(
85
+ "AccessDeniedException: User: arn:aws:iam::123:user/dev is not authorized to perform: bedrock:InvokeModel on resource: arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2",
86
+ );
87
+ expect(r?.category).toBe("aws-access");
88
+ expect(r!.message).toContain("bedrock:InvokeModel");
89
+ });
90
+
91
+ test("not authorized to perform (phrase match)", () => {
92
+ const r = classifyAwsSdkError("User is not authorized to perform: bedrock:InvokeModel");
93
+ expect(r?.category).toBe("aws-access");
94
+ });
95
+ });
96
+
97
+ describe("classifyAwsSdkError — aws-model", () => {
98
+ test("ValidationException", () => {
99
+ const r = classifyAwsSdkError(
100
+ "ValidationException: Invocation of model ID anthropic.claude-v99 with on-demand throughput isn't supported",
101
+ );
102
+ expect(r?.category).toBe("aws-model");
103
+ expect(r!.message).toContain("MODEL_OVERRIDE");
104
+ });
105
+
106
+ test("ResourceNotFoundException", () => {
107
+ const r = classifyAwsSdkError("ResourceNotFoundException: Could not find model");
108
+ expect(r?.category).toBe("aws-model");
109
+ });
110
+
111
+ test("ModelTimeoutException", () => {
112
+ const r = classifyAwsSdkError(
113
+ "ModelTimeoutException: The model timed out processing your request",
114
+ );
115
+ expect(r?.category).toBe("aws-model");
116
+ });
117
+
118
+ test("ModelNotReadyException", () => {
119
+ const r = classifyAwsSdkError("ModelNotReadyException: The model is not ready for inference");
120
+ expect(r?.category).toBe("aws-model");
121
+ });
122
+ });
123
+
124
+ describe("classifyAwsSdkError — priority ordering", () => {
125
+ test("aws-auth wins over aws-model when both match (ExpiredToken + ValidationException)", () => {
126
+ // Should not happen in practice, but priority must be deterministic
127
+ const r = classifyAwsSdkError("ExpiredTokenException and also ValidationException");
128
+ expect(r?.category).toBe("aws-auth");
129
+ });
130
+ });
131
+
132
+ describe("classifyAwsSdkError — no-match", () => {
133
+ test("returns null for empty string", () => {
134
+ expect(classifyAwsSdkError("")).toBeNull();
135
+ });
136
+
137
+ test("returns null for unrelated error", () => {
138
+ expect(classifyAwsSdkError("TypeError: Cannot read property 'foo' of undefined")).toBeNull();
139
+ });
140
+
141
+ test("returns null for generic network error", () => {
142
+ expect(classifyAwsSdkError("ECONNREFUSED 127.0.0.1:3013")).toBeNull();
143
+ });
144
+
145
+ test("returns null for Claude API error (not AWS)", () => {
146
+ expect(classifyAwsSdkError("401 Unauthorized: Invalid API key")).toBeNull();
147
+ });
148
+ });
@@ -794,6 +794,18 @@ describe("ClaudeManagedAdapter (Phase 4) — repo provisioning + cost data", ()
794
794
  });
795
795
 
796
796
  test("CLAUDE_MANAGED_MODEL_PRICING covers sonnet, opus, haiku at minimum", () => {
797
+ expect(CLAUDE_MANAGED_MODEL_PRICING["claude-fable-5"]).toEqual({
798
+ inputPerMillion: 10.0,
799
+ outputPerMillion: 50.0,
800
+ cacheReadPerMillion: 1.0,
801
+ cacheWritePerMillion: 12.5,
802
+ });
803
+ expect(CLAUDE_MANAGED_MODEL_PRICING["claude-mythos-5"]).toEqual({
804
+ inputPerMillion: 10.0,
805
+ outputPerMillion: 50.0,
806
+ cacheReadPerMillion: 1.0,
807
+ cacheWritePerMillion: 12.5,
808
+ });
797
809
  expect(CLAUDE_MANAGED_MODEL_PRICING["claude-sonnet-4-6"]).toBeDefined();
798
810
  expect(CLAUDE_MANAGED_MODEL_PRICING["claude-opus-4-7"]).toBeDefined();
799
811
  expect(CLAUDE_MANAGED_MODEL_PRICING["claude-haiku-4-5"]).toBeDefined();
@@ -59,7 +59,6 @@ describe("runClaudeManagedSetupFlow — happy path", () => {
59
59
  const log = mock((_msg: string) => undefined);
60
60
 
61
61
  const result = await runClaudeManagedSetupFlow(baseConfig, {
62
- // biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
63
62
  client: client as any,
64
63
  fetchConfig,
65
64
  upsert,
@@ -132,7 +131,6 @@ describe("runClaudeManagedSetupFlow — happy path", () => {
132
131
  await runClaudeManagedSetupFlow(
133
132
  { ...baseConfig, mcpBaseUrl: "https://swarm.example.com/" },
134
133
  {
135
- // biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
136
134
  client: client as any,
137
135
  fetchConfig: mock(async () => null),
138
136
  upsert: mock(async () => undefined),
@@ -175,7 +173,6 @@ describe("runClaudeManagedSetupFlow — idempotent re-run", () => {
175
173
  const uploadOne = mock(async () => null);
176
174
 
177
175
  const result = await runClaudeManagedSetupFlow(baseConfig, {
178
- // biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
179
176
  client: client as any,
180
177
  fetchConfig,
181
178
  upsert,
@@ -208,7 +205,6 @@ describe("runClaudeManagedSetupFlow — idempotent re-run", () => {
208
205
  await runClaudeManagedSetupFlow(
209
206
  { ...baseConfig, force: true },
210
207
  {
211
- // biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
212
208
  client: client as any,
213
209
  fetchConfig,
214
210
  upsert,
@@ -24,11 +24,7 @@ import {
24
24
  credentialsToAuthJson,
25
25
  } from "../providers/codex-oauth/auth-json.js";
26
26
  import { materializeCodexAuthJson } from "../providers/codex-oauth/auth-json-fs.js";
27
- import {
28
- loadAllCodexOAuthSlots,
29
- persistCodexOAuth,
30
- storeCodexOAuth,
31
- } from "../providers/codex-oauth/storage.js";
27
+ import { loadAllCodexOAuthSlots, persistCodexOAuth } from "../providers/codex-oauth/storage.js";
32
28
  import type { CodexOAuthCredentials } from "../providers/codex-oauth/types.js";
33
29
 
34
30
  // ─── Fixtures ────────────────────────────────────────────────────────────────
@@ -117,7 +113,7 @@ afterEach(() => {
117
113
  describe("Scenario 1 — 3-slot round-trip with availability filter", () => {
118
114
  it("selects from available slots [1,2] and materialises the correct creds into auth.json", async () => {
119
115
  // Mock API: three slots in config store.
120
- globalThis.fetch = async (url: string | URL | Request) => {
116
+ globalThis.fetch = async (_url: string | URL | Request) => {
121
117
  return makeConfigResponse();
122
118
  // (available-indices endpoint not called by loadAllCodexOAuthSlots)
123
119
  };
@@ -8,6 +8,13 @@ import {
8
8
  } from "../utils/context-window";
9
9
 
10
10
  describe("getContextWindowSize", () => {
11
+ test("returns 1M for fable and mythos models", () => {
12
+ expect(getContextWindowSize("claude-fable-5")).toBe(1_000_000);
13
+ expect(getContextWindowSize("claude-mythos-5")).toBe(1_000_000);
14
+ expect(getContextWindowSize("fable")).toBe(1_000_000);
15
+ expect(getContextWindowSize("mythos")).toBe(1_000_000);
16
+ });
17
+
11
18
  test("returns 1M for opus models", () => {
12
19
  expect(getContextWindowSize("claude-opus-4-8")).toBe(1_000_000);
13
20
  expect(getContextWindowSize("claude-opus-4-7")).toBe(1_000_000);
@@ -25,7 +25,6 @@ async function api(
25
25
  method: string,
26
26
  path: string,
27
27
  opts: { body?: unknown; agentId?: string; headers?: Record<string, string> } = {},
28
- // biome-ignore lint/suspicious/noExplicitAny: test helper needs flexible body type
29
28
  ): Promise<{ status: number; body: any; ok: boolean }> {
30
29
  const headers: Record<string, string> = {
31
30
  "Content-Type": "application/json",
@@ -41,7 +40,6 @@ async function api(
41
40
  });
42
41
 
43
42
  const text = await res.text();
44
- // biome-ignore lint/suspicious/noExplicitAny: body can be parsed JSON or raw text
45
43
  let body: any;
46
44
  try {
47
45
  body = JSON.parse(text);
@@ -427,9 +425,9 @@ describe("Tasks", () => {
427
425
  expect(status).toBe(404);
428
426
  });
429
427
 
430
- test("PUT /api/tasks/:id/claude-session — update session ID", async () => {
428
+ test("PUT /api/tasks/:id/session — update session ID", async () => {
431
429
  const sessionId = randomUUID();
432
- const { status, body } = await put(`/api/tasks/${ids.task2}/claude-session`, {
430
+ const { status, body } = await put(`/api/tasks/${ids.task2}/session`, {
433
431
  agentId: ids.workerAgent,
434
432
  body: { claudeSessionId: sessionId },
435
433
  });
@@ -437,8 +435,8 @@ describe("Tasks", () => {
437
435
  expect(body.claudeSessionId).toBe(sessionId);
438
436
  });
439
437
 
440
- test("PUT /api/tasks/:id/claude-session — missing fields returns 400", async () => {
441
- const { status } = await put(`/api/tasks/${ids.task2}/claude-session`, {
438
+ test("PUT /api/tasks/:id/session — missing fields returns 400", async () => {
439
+ const { status } = await put(`/api/tasks/${ids.task2}/session`, {
442
440
  body: {},
443
441
  });
444
442
  expect(status).toBe(400);
@@ -1014,6 +1012,25 @@ describe("Schedule CRUD", () => {
1014
1012
  expect(body.task.id).toBeDefined();
1015
1013
  });
1016
1014
 
1015
+ test("POST /api/schedules/:id/run — propagates modelTier to the created task", async () => {
1016
+ const { body: created } = await post("/api/schedules", {
1017
+ body: {
1018
+ name: "model-tier-manual-run",
1019
+ taskTemplate: "Run model tier integration test",
1020
+ cronExpression: "0 * * * *",
1021
+ modelTier: "smart",
1022
+ },
1023
+ });
1024
+
1025
+ const { status, body } = await post(`/api/schedules/${created.id}/run`);
1026
+ expect(status).toBe(200);
1027
+ expect(body.task).toBeDefined();
1028
+ expect(body.task.model).toBeUndefined();
1029
+ expect(body.task.modelTier).toBe("smart");
1030
+
1031
+ await del(`/api/schedules/${created.id}`);
1032
+ });
1033
+
1017
1034
  test("POST /api/schedules/:id/run — disabled schedule returns 400", async () => {
1018
1035
  // Disable the schedule first
1019
1036
  await put(`/api/schedules/${scheduleId}`, {
@@ -48,7 +48,6 @@ async function api(
48
48
  method: string,
49
49
  path: string,
50
50
  opts: { body?: unknown; agentId?: string } = {},
51
- // biome-ignore lint/suspicious/noExplicitAny: test helper
52
51
  ): Promise<{ status: number; body: any }> {
53
52
  const headers: Record<string, string> = {
54
53
  "Content-Type": "application/json",
@@ -61,7 +60,6 @@ async function api(
61
60
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
62
61
  });
63
62
  const text = await res.text();
64
- // biome-ignore lint/suspicious/noExplicitAny: body may be JSON or text
65
63
  let body: any;
66
64
  try {
67
65
  body = JSON.parse(text);
@@ -41,7 +41,6 @@ async function api(
41
41
  method: string,
42
42
  path: string,
43
43
  opts: { body?: unknown; agentId?: string } = {},
44
- // biome-ignore lint/suspicious/noExplicitAny: test helper
45
44
  ): Promise<{ status: number; body: any }> {
46
45
  const headers: Record<string, string> = {
47
46
  "Content-Type": "application/json",
@@ -54,7 +53,6 @@ async function api(
54
53
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
55
54
  });
56
55
  const text = await res.text();
57
- // biome-ignore lint/suspicious/noExplicitAny: body may be JSON or text
58
56
  let body: any;
59
57
  try {
60
58
  body = JSON.parse(text);
@@ -64,7 +64,6 @@ async function api(
64
64
  method: string,
65
65
  path: string,
66
66
  opts: { body?: unknown; agentId?: string; sourceTaskId?: string } = {},
67
- // biome-ignore lint/suspicious/noExplicitAny: test helper
68
67
  ): Promise<{ status: number; body: any }> {
69
68
  const headers: Record<string, string> = {
70
69
  "Content-Type": "application/json",
@@ -78,7 +77,6 @@ async function api(
78
77
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
79
78
  });
80
79
  const text = await res.text();
81
- // biome-ignore lint/suspicious/noExplicitAny: body may be JSON or text
82
80
  let body: any;
83
81
  try {
84
82
  body = JSON.parse(text);
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
- import { closeDb, createAgent, getDb, initDb } from "../be/db";
3
+ import { closeDb, createAgent, getDb, initDb, isSqliteVecAvailable } from "../be/db";
4
4
  import { serializeEmbedding } from "../be/embedding";
5
5
  import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
6
6
 
@@ -19,6 +19,16 @@ describe("SqliteMemoryStore", () => {
19
19
  return embedding;
20
20
  }
21
21
 
22
+ function skipVecAssertionsWhenUnavailable(): boolean {
23
+ if (isSqliteVecAvailable()) return false;
24
+
25
+ const health = store.getHealth();
26
+ expect(health.sqliteVec.extensionLoaded).toBe(false);
27
+ expect(health.retrievalMode).toBe("fallback");
28
+ expect(health.reasons).toContain("sqlite_vec_extension_unavailable");
29
+ return true;
30
+ }
31
+
22
32
  beforeAll(async () => {
23
33
  for (const suffix of ["", "-wal", "-shm"]) {
24
34
  try {
@@ -221,6 +231,8 @@ describe("SqliteMemoryStore", () => {
221
231
  });
222
232
 
223
233
  test("uses sqlite-vec for 512d embeddings with scope-filter parity", () => {
234
+ if (skipVecAssertionsWhenUnavailable()) return;
235
+
224
236
  for (let i = 0; i < 6; i++) {
225
237
  const otherAgent = store.store({
226
238
  agentId: agentB,
@@ -256,6 +268,8 @@ describe("SqliteMemoryStore", () => {
256
268
 
257
269
  describe("memory_vec population", () => {
258
270
  test("populates existing embeddings on startup and reports health counts", () => {
271
+ if (skipVecAssertionsWhenUnavailable()) return;
272
+
259
273
  const raw = store.store({
260
274
  agentId: agentA,
261
275
  scope: "agent",
@@ -282,6 +296,8 @@ describe("SqliteMemoryStore", () => {
282
296
  });
283
297
 
284
298
  test("rebuilds an old non-cosine memory_vec table from agent_memory", () => {
299
+ if (skipVecAssertionsWhenUnavailable()) return;
300
+
285
301
  const raw = store.store({
286
302
  agentId: agentA,
287
303
  scope: "agent",
@@ -473,6 +489,8 @@ describe("SqliteMemoryStore", () => {
473
489
  });
474
490
 
475
491
  test("also removes corresponding vec rows", () => {
492
+ if (skipVecAssertionsWhenUnavailable()) return;
493
+
476
494
  const db = getDb();
477
495
  const emb = vector({ 0: 0.9, 100: 0.1 });
478
496
 
@@ -479,6 +479,57 @@ describe("Memory System", () => {
479
479
  const page2 = store.list(listAgent, { scope: "agent", limit: 3, offset: 3 });
480
480
  expect(page2.length).toBe(2);
481
481
  });
482
+
483
+ test("counts memories with the same filters used by list", () => {
484
+ const countAgent = "eeee0000-0000-4000-8000-000000000105";
485
+ createAgent({ id: countAgent, name: "Count Agent", isLead: false, status: "idle" });
486
+
487
+ store.store({
488
+ agentId: countAgent,
489
+ scope: "agent",
490
+ name: "Count match 1",
491
+ content: "Content",
492
+ source: "file_index",
493
+ sourcePath: "/workspace/src/MemoryPage.tsx",
494
+ });
495
+ store.store({
496
+ agentId: countAgent,
497
+ scope: "swarm",
498
+ name: "Count match 2",
499
+ content: "Content",
500
+ source: "file_index",
501
+ sourcePath: "/workspace/SRC/memory-page.tsx",
502
+ });
503
+ store.store({
504
+ agentId: countAgent,
505
+ scope: "agent",
506
+ name: "Wrong source",
507
+ content: "Content",
508
+ source: "manual",
509
+ sourcePath: "/workspace/src/MemoryPage.tsx",
510
+ });
511
+ store.store({
512
+ agentId: agentA,
513
+ scope: "agent",
514
+ name: "Wrong owner",
515
+ content: "Content",
516
+ source: "file_index",
517
+ sourcePath: "/workspace/src/MemoryPage.tsx",
518
+ });
519
+
520
+ const filters = {
521
+ scope: "all" as const,
522
+ isLead: true,
523
+ ownerAgentId: countAgent,
524
+ source: "file_index" as const,
525
+ sourcePath: "src/memory",
526
+ };
527
+
528
+ expect(store.count("", filters)).toBe(2);
529
+ expect(store.list("", { ...filters, limit: 1, offset: 0 })).toHaveLength(1);
530
+ expect(store.list("", { ...filters, limit: 1, offset: 1 })).toHaveLength(1);
531
+ expect(store.list("", { ...filters, limit: 1, offset: 2 })).toHaveLength(0);
532
+ });
482
533
  });
483
534
 
484
535
  describe("store.delete (deleteMemory)", () => {
@@ -76,7 +76,60 @@ describe("Metrics HTTP API", () => {
76
76
  const body = (await res.json()) as { metrics: Metric[]; total: number };
77
77
  expect(body.total).toBeGreaterThanOrEqual(1);
78
78
  const starter = body.metrics.find((metric) => metric.slug === "swarm-operations-overview");
79
- expect(starter?.definition.widgets.map((widget) => widget.viz.type)).toContain("multi-line");
79
+ expect(starter?.definition.layout?.columns).toBe(3);
80
+ expect(starter?.definition.widgets.map((widget) => widget.id)).toEqual([
81
+ "tasks-created-per-day",
82
+ "usage-by-user",
83
+ "usage-by-model",
84
+ "avg-cost-per-task-by-model",
85
+ "avg-task-time-by-model",
86
+ "cost-per-minute-by-model",
87
+ "cost-per-minute-by-agent",
88
+ "agent-performance",
89
+ "task-outcomes-by-day",
90
+ "recent-task-outcomes",
91
+ ]);
92
+ expect(
93
+ starter?.definition.variables?.find((variable) => variable.key === "userFilter"),
94
+ ).toMatchObject({
95
+ type: "select",
96
+ defaultValue: "all",
97
+ optionsQuery: { valueKey: "id", labelKey: "label" },
98
+ });
99
+ expect(
100
+ starter?.definition.variables?.find((variable) => variable.key === "agentFilter"),
101
+ ).toMatchObject({
102
+ type: "select",
103
+ defaultValue: "all",
104
+ optionsQuery: { valueKey: "id", labelKey: "label" },
105
+ });
106
+
107
+ const run = await fetch(`${BASE}/api/metrics/definitions/${starter!.id}/run`, {
108
+ method: "POST",
109
+ headers,
110
+ body: JSON.stringify({ variables: {} }),
111
+ });
112
+ expect(run.status).toBe(200);
113
+ const runBody = (await run.json()) as MetricRunResponse & {
114
+ metric: Metric;
115
+ variables: Record<string, string>;
116
+ };
117
+ expect(runBody.variables.userFilter).toBe("all");
118
+ expect(runBody.variables.agentFilter).toBe("all");
119
+ expect(
120
+ runBody.metric.definition.variables?.find((variable) => variable.key === "userFilter")
121
+ ?.options?.[0],
122
+ ).toEqual({
123
+ label: "All requesters",
124
+ value: "all",
125
+ });
126
+ expect(
127
+ runBody.metric.definition.variables?.find((variable) => variable.key === "agentFilter")
128
+ ?.options?.[0],
129
+ ).toEqual({
130
+ label: "All agents",
131
+ value: "all",
132
+ });
80
133
  });
81
134
 
82
135
  test("create, run, update snapshots prior definition", async () => {
@@ -221,8 +274,79 @@ describe("Metrics HTTP API", () => {
221
274
  expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
222
275
  });
223
276
 
277
+ test("run resolves dynamic select variable options from read-only SQL", async () => {
278
+ const created = await fetch(`${BASE}/api/metrics/definitions`, {
279
+ method: "POST",
280
+ headers,
281
+ body: JSON.stringify({
282
+ slug: "dynamic-variable-options",
283
+ title: "Dynamic Variable Options",
284
+ definition: {
285
+ version: 1,
286
+ variables: [
287
+ {
288
+ key: "agent",
289
+ label: "Agent",
290
+ type: "select",
291
+ optionsQuery: {
292
+ sql: "SELECT 'agent-a' AS id, 'Agent A' AS name UNION ALL SELECT 'agent-b' AS id, 'Agent B' AS name",
293
+ valueKey: "id",
294
+ labelKey: "name",
295
+ },
296
+ },
297
+ ],
298
+ widgets: [
299
+ {
300
+ id: "selected-agent",
301
+ title: "Selected agent",
302
+ query: {
303
+ sql: "SELECT ? AS agent",
304
+ params: ["{{agent}}"],
305
+ maxRows: 10,
306
+ },
307
+ viz: { type: "table", columns: [{ key: "agent", label: "Agent" }] },
308
+ },
309
+ ],
310
+ },
311
+ }),
312
+ });
313
+ expect(created.status).toBe(201);
314
+ const { id } = (await created.json()) as { id: string; version: number };
315
+
316
+ const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
317
+ method: "POST",
318
+ headers,
319
+ body: JSON.stringify({ variables: { agent: "agent-b" } }),
320
+ });
321
+ expect(run.status).toBe(200);
322
+ const runBody = (await run.json()) as MetricRunResponse & {
323
+ metric: Metric;
324
+ variables: Record<string, string>;
325
+ };
326
+ expect(runBody.variables.agent).toBe("agent-b");
327
+ expect(runBody.metric.definition.variables?.[0]?.options).toEqual([
328
+ { label: "Agent A", value: "agent-a" },
329
+ { label: "Agent B", value: "agent-b" },
330
+ ]);
331
+ expect(runBody.widgets[0]?.result.rows[0]).toEqual({ agent: "agent-b" });
332
+
333
+ const defaultedRun = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
334
+ method: "POST",
335
+ headers,
336
+ body: JSON.stringify({ variables: {} }),
337
+ });
338
+ expect(defaultedRun.status).toBe(200);
339
+ const defaultedBody = (await defaultedRun.json()) as { variables: Record<string, string> };
340
+ expect(defaultedBody.variables.agent).toBe("agent-a");
341
+ });
342
+
224
343
  test("saved metric SQL rejects writes and multiple statements", async () => {
225
- for (const sql of ["DELETE FROM agent_tasks", "SELECT 1; SELECT 2"]) {
344
+ for (const [sql, target] of [
345
+ ["DELETE FROM agent_tasks", "widget"],
346
+ ["SELECT 1; SELECT 2", "widget"],
347
+ ["DELETE FROM agents", "variable"],
348
+ ["SELECT 1; SELECT 2", "variable"],
349
+ ] as const) {
226
350
  const res = await fetch(`${BASE}/api/metrics/definitions`, {
227
351
  method: "POST",
228
352
  headers,
@@ -230,11 +354,21 @@ describe("Metrics HTTP API", () => {
230
354
  title: "Bad Metric",
231
355
  definition: {
232
356
  version: 1,
357
+ variables:
358
+ target === "variable"
359
+ ? [
360
+ {
361
+ key: "agent",
362
+ type: "select",
363
+ optionsQuery: { sql, valueKey: "id" },
364
+ },
365
+ ]
366
+ : undefined,
233
367
  widgets: [
234
368
  {
235
369
  id: "bad",
236
370
  title: "Bad",
237
- query: { sql },
371
+ query: { sql: target === "widget" ? sql : "SELECT 1 AS x" },
238
372
  viz: { type: "stat", value: "x" },
239
373
  },
240
374
  ],