@desplega.ai/agent-swarm 1.93.0 → 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 (67) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +180 -1
  3. package/package.json +1 -1
  4. package/src/be/db.ts +63 -7
  5. package/src/be/migrations/090_model_tiers.sql +2 -0
  6. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  7. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  8. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  9. package/src/be/migrations/runner.ts +52 -0
  10. package/src/be/modelsdev-cache.json +2060 -198
  11. package/src/be/scripts/boot-reembed.ts +74 -0
  12. package/src/be/scripts/db.ts +19 -3
  13. package/src/be/seed/index.ts +1 -1
  14. package/src/be/seed/registry.ts +2 -2
  15. package/src/be/seed/runner.ts +5 -5
  16. package/src/be/seed/types.ts +6 -1
  17. package/src/be/seed-pricing.ts +1 -0
  18. package/src/be/seed-scripts/index.ts +3 -2
  19. package/src/commands/runner.ts +83 -13
  20. package/src/http/index.ts +13 -2
  21. package/src/http/metrics.ts +55 -6
  22. package/src/http/schedules.ts +16 -15
  23. package/src/http/script-runs.ts +7 -1
  24. package/src/http/scripts.ts +147 -1
  25. package/src/http/tasks.ts +7 -0
  26. package/src/model-tiers.ts +140 -0
  27. package/src/providers/claude-managed-models.ts +9 -0
  28. package/src/providers/opencode-adapter.ts +1 -0
  29. package/src/providers/pi-mono-adapter.ts +78 -6
  30. package/src/scheduler/scheduler.ts +22 -34
  31. package/src/server-user.ts +8 -2
  32. package/src/slack/responses.ts +39 -11
  33. package/src/slack/watcher.ts +121 -8
  34. package/src/tests/agents-list-model-display.test.ts +13 -0
  35. package/src/tests/aws-error-classifier.test.ts +148 -0
  36. package/src/tests/claude-managed-adapter.test.ts +12 -0
  37. package/src/tests/context-window.test.ts +7 -0
  38. package/src/tests/http-api-integration.test.ts +19 -0
  39. package/src/tests/metrics-http.test.ts +137 -3
  40. package/src/tests/migration-046-budgets.test.ts +33 -0
  41. package/src/tests/migration-runner-regressions.test.ts +69 -0
  42. package/src/tests/model-control.test.ts +162 -46
  43. package/src/tests/opencode-adapter.test.ts +9 -0
  44. package/src/tests/pi-mono-adapter.test.ts +319 -0
  45. package/src/tests/providers/pi-cost.test.ts +9 -0
  46. package/src/tests/runner-fallback-output.test.ts +50 -0
  47. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  48. package/src/tests/scripts-embeddings.test.ts +90 -0
  49. package/src/tests/seed.test.ts +26 -1
  50. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  51. package/src/tests/slack-watcher.test.ts +66 -0
  52. package/src/tests/workflow-agent-task.test.ts +5 -2
  53. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  54. package/src/tools/memory-get.ts +11 -0
  55. package/src/tools/memory-search.ts +18 -0
  56. package/src/tools/schedules/create-schedule.ts +71 -70
  57. package/src/tools/schedules/update-schedule.ts +43 -31
  58. package/src/tools/send-task.ts +16 -5
  59. package/src/tools/task-action.ts +11 -3
  60. package/src/types.ts +29 -0
  61. package/src/utils/aws-error-classifier.ts +97 -0
  62. package/src/utils/context-window.ts +2 -0
  63. package/src/utils/credentials.test.ts +68 -0
  64. package/src/utils/credentials.ts +44 -3
  65. package/src/utils/pretty-print.ts +25 -10
  66. package/src/workflows/engine.ts +3 -2
  67. package/src/workflows/executors/agent-task.ts +3 -1
@@ -15,6 +15,20 @@ import {
15
15
  // Re-export for backward compatibility
16
16
  export { markdownToSlack } from "./blocks";
17
17
 
18
+ export type SlackUpdateResult = "ok" | "not_found" | "failed";
19
+
20
+ function classifySlackUpdateError(error: unknown): SlackUpdateResult {
21
+ const errorCode = (error as { data?: { error?: string } } | undefined)?.data?.error;
22
+ if (
23
+ errorCode === "message_not_found" ||
24
+ errorCode === "channel_not_found" ||
25
+ errorCode === "thread_not_found"
26
+ ) {
27
+ return "not_found";
28
+ }
29
+ return "failed";
30
+ }
31
+
18
32
  const isDev = process.env.ENV === "development";
19
33
 
20
34
  /**
@@ -140,12 +154,12 @@ export async function updateProgressInPlace(
140
154
  task: AgentTask,
141
155
  progress: string,
142
156
  messageTs: string,
143
- ): Promise<boolean> {
157
+ ): Promise<SlackUpdateResult> {
144
158
  const app = getSlackApp();
145
- if (!app || !task.slackChannelId || !task.agentId) return false;
159
+ if (!app || !task.slackChannelId || !task.agentId) return "failed";
146
160
 
147
161
  const agent = getAgentById(task.agentId);
148
- if (!agent) return false;
162
+ if (!agent) return "failed";
149
163
 
150
164
  const blocks = buildProgressBlocks({ agentName: agent.name, taskId: task.id, progress });
151
165
 
@@ -157,10 +171,17 @@ export async function updateProgressInPlace(
157
171
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
158
172
  blocks: blocks as any,
159
173
  });
160
- return true;
174
+ return "ok";
161
175
  } catch (error) {
162
- console.error(`[Slack] Failed to update progress in-place:`, error);
163
- return false;
176
+ const result = classifySlackUpdateError(error);
177
+ if (result === "not_found") {
178
+ console.warn(
179
+ `[Slack] Progress message missing for task ${task.id} ts=${messageTs}; will repost`,
180
+ );
181
+ } else {
182
+ console.error(`[Slack] Failed to update progress in-place:`, error);
183
+ }
184
+ return result;
164
185
  }
165
186
  }
166
187
 
@@ -233,9 +254,9 @@ export async function updateTreeMessage(
233
254
  messageTs: string,
234
255
  blocks: unknown[],
235
256
  fallbackText: string,
236
- ): Promise<boolean> {
257
+ ): Promise<SlackUpdateResult> {
237
258
  const app = getSlackApp();
238
- if (!app) return false;
259
+ if (!app) return "failed";
239
260
 
240
261
  try {
241
262
  await app.client.chat.update({
@@ -245,10 +266,17 @@ export async function updateTreeMessage(
245
266
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
246
267
  blocks: blocks as any,
247
268
  });
248
- return true;
269
+ return "ok";
249
270
  } catch (error) {
250
- console.error(`[Slack] Failed to update tree message:`, error);
251
- return false;
271
+ const result = classifySlackUpdateError(error);
272
+ if (result === "not_found") {
273
+ console.warn(
274
+ `[Slack] Tree message missing for channel=${channelId} ts=${messageTs}; will repost`,
275
+ );
276
+ } else {
277
+ console.error(`[Slack] Failed to update tree message:`, error);
278
+ }
279
+ return result;
252
280
  }
253
281
  }
254
282
 
@@ -5,6 +5,7 @@ import {
5
5
  getInProgressSlackTasks,
6
6
  getTaskAttachments,
7
7
  getTaskById,
8
+ setSlackMessageTracking,
8
9
  } from "../be/db";
9
10
  import type { AgentTask } from "../types";
10
11
  import { getSlackApp } from "./app";
@@ -76,6 +77,15 @@ export function registerTreeMessage(
76
77
  // Also register in legacy flat map so existing watcher processing still works
77
78
  taskMessages.set(taskId, { channelId, threadTs, messageTs });
78
79
 
80
+ try {
81
+ setSlackMessageTracking(taskId, {
82
+ slackProgressMessageTs: messageTs,
83
+ slackTreeRootMessageTs: messageTs,
84
+ });
85
+ } catch (error) {
86
+ console.error(`[Slack] Failed to persist message tracking for task ${taskId}:`, error);
87
+ }
88
+
79
89
  console.log(`[Slack] Registered task ${taskId.slice(0, 8)} in tree message ${messageTs}`);
80
90
  }
81
91
 
@@ -193,6 +203,12 @@ export function _getTreeMessages(): Map<string, TreeMessageState> {
193
203
  export function _getTaskToTree(): Map<string, string> {
194
204
  return taskToTree;
195
205
  }
206
+ export function _getTaskMessages(): Map<
207
+ string,
208
+ { channelId: string; threadTs: string; messageTs: string }
209
+ > {
210
+ return taskMessages;
211
+ }
196
212
  export function _getLastRenderedTree(): Map<string, string> {
197
213
  return lastRenderedTree;
198
214
  }
@@ -306,13 +322,35 @@ export async function processTreeMessages(): Promise<void> {
306
322
  : `Tasks in progress: ${rootNames}`;
307
323
 
308
324
  // Update the Slack message
309
- const success = await updateTreeMessage(tree.channelId, messageTs, blocks, fallbackText);
325
+ const result = await updateTreeMessage(tree.channelId, messageTs, blocks, fallbackText);
326
+ const success = result === "ok";
310
327
  if (success) {
311
328
  lastRenderedTree.set(messageTs, serialized);
312
329
  treeLastUpdateTime.set(messageTs, now);
313
330
  console.log(
314
331
  `[Slack] Updated tree message ${messageTs} (${nodes.length} root(s), terminal=${fullyTerminal})`,
315
332
  );
333
+ } else if (result === "not_found") {
334
+ const taskIds = Array.from(tree.rootTaskIds);
335
+ for (const taskId of taskIds) {
336
+ taskToTree.delete(taskId);
337
+ taskMessages.delete(taskId);
338
+ try {
339
+ setSlackMessageTracking(taskId, {
340
+ slackProgressMessageTs: null,
341
+ slackTreeRootMessageTs: null,
342
+ });
343
+ } catch (error) {
344
+ console.error(`[Slack] Failed to clear stale message tracking for ${taskId}:`, error);
345
+ }
346
+ }
347
+ treeMessages.delete(messageTs);
348
+ lastRenderedTree.delete(messageTs);
349
+ treeLastUpdateTime.delete(messageTs);
350
+ console.warn(
351
+ `[Slack] Dropped stale tree ${messageTs} (${taskIds.length} task(s)); will repost on next tick`,
352
+ );
353
+ continue;
316
354
  }
317
355
 
318
356
  // DM channels: set assistant status in parallel for typing indicator UX
@@ -437,6 +475,50 @@ export function startTaskWatcher(intervalMs = 3000): void {
437
475
  }
438
476
  console.log(`[Slack] Initialized with ${existingCompleted.length} existing completed tasks`);
439
477
 
478
+ let hydratedTrees = 0;
479
+ let hydratedFlat = 0;
480
+ for (const task of getInProgressSlackTasks()) {
481
+ if (!task.slackChannelId || !task.slackThreadTs) continue;
482
+
483
+ const treeTs = task.slackTreeRootMessageTs;
484
+ const progressTs = task.slackProgressMessageTs;
485
+
486
+ if (treeTs) {
487
+ let tree = treeMessages.get(treeTs);
488
+ if (!tree) {
489
+ tree = {
490
+ channelId: task.slackChannelId,
491
+ threadTs: task.slackThreadTs,
492
+ messageTs: treeTs,
493
+ rootTaskIds: new Set(),
494
+ };
495
+ treeMessages.set(treeTs, tree);
496
+ }
497
+ tree.rootTaskIds.add(task.id);
498
+ taskToTree.set(task.id, treeTs);
499
+ taskMessages.set(task.id, {
500
+ channelId: task.slackChannelId,
501
+ threadTs: task.slackThreadTs,
502
+ messageTs: treeTs,
503
+ });
504
+ if (task.progress) sentProgress.set(task.id, task.progress);
505
+ hydratedTrees++;
506
+ } else if (progressTs) {
507
+ taskMessages.set(task.id, {
508
+ channelId: task.slackChannelId,
509
+ threadTs: task.slackThreadTs,
510
+ messageTs: progressTs,
511
+ });
512
+ if (task.progress) sentProgress.set(task.id, task.progress);
513
+ hydratedFlat++;
514
+ }
515
+ }
516
+ if (hydratedTrees > 0 || hydratedFlat > 0) {
517
+ console.log(
518
+ `[Slack] Hydrated ${hydratedTrees} tree task(s) and ${hydratedFlat} flat task(s) from DB`,
519
+ );
520
+ }
521
+
440
522
  watcherInterval = setInterval(async () => {
441
523
  // Prevent overlapping processing cycles
442
524
  if (isProcessing || !getSlackApp()) return;
@@ -515,8 +597,20 @@ export function startTaskWatcher(intervalMs = 3000): void {
515
597
  sentProgress.set(task.id, "__in_progress__");
516
598
  lastSendTime.set(progressKey, now);
517
599
  try {
518
- await updateProgressInPlace(task, "Starting...", tracked.messageTs);
519
- console.log(`[Slack] Updated to in-progress for task ${task.id.slice(0, 8)}`);
600
+ const result = await updateProgressInPlace(task, "Starting...", tracked.messageTs);
601
+ if (result === "not_found") {
602
+ taskMessages.delete(task.id);
603
+ sentProgress.delete(task.id);
604
+ setSlackMessageTracking(task.id, {
605
+ slackProgressMessageTs: null,
606
+ slackTreeRootMessageTs: null,
607
+ });
608
+ } else if (result === "ok") {
609
+ console.log(`[Slack] Updated to in-progress for task ${task.id.slice(0, 8)}`);
610
+ } else {
611
+ sentProgress.delete(task.id);
612
+ lastSendTime.delete(progressKey);
613
+ }
520
614
  } catch (error) {
521
615
  sentProgress.delete(task.id);
522
616
  lastSendTime.delete(progressKey);
@@ -535,23 +629,42 @@ export function startTaskWatcher(intervalMs = 3000): void {
535
629
  sentProgress.set(task.id, task.progress);
536
630
  lastSendTime.set(progressKey, now);
537
631
  try {
632
+ let postedTs: string | undefined;
538
633
  if (tracked) {
539
634
  // Update the existing message in-place via chat.update
540
- await updateProgressInPlace(task, task.progress, tracked.messageTs);
541
- console.log(`[Slack] Updated progress in-place for task ${task.id.slice(0, 8)}`);
635
+ const result = await updateProgressInPlace(task, task.progress, tracked.messageTs);
636
+ if (result === "ok") {
637
+ console.log(`[Slack] Updated progress in-place for task ${task.id.slice(0, 8)}`);
638
+ } else if (result === "not_found") {
639
+ taskMessages.delete(task.id);
640
+ postedTs = await sendProgressUpdate(task, task.progress);
641
+ if (postedTs && task.slackChannelId && task.slackThreadTs) {
642
+ taskMessages.set(task.id, {
643
+ channelId: task.slackChannelId,
644
+ threadTs: task.slackThreadTs,
645
+ messageTs: postedTs,
646
+ });
647
+ }
648
+ } else {
649
+ sentProgress.delete(task.id);
650
+ lastSendTime.delete(progressKey);
651
+ }
542
652
  } else {
543
653
  // No tracked message (e.g., multi-task assignment or server restart)
544
654
  // Post a new progress message and track its ts
545
- const messageTs = await sendProgressUpdate(task, task.progress);
546
- if (messageTs && task.slackChannelId && task.slackThreadTs) {
655
+ postedTs = await sendProgressUpdate(task, task.progress);
656
+ if (postedTs && task.slackChannelId && task.slackThreadTs) {
547
657
  taskMessages.set(task.id, {
548
658
  channelId: task.slackChannelId,
549
659
  threadTs: task.slackThreadTs,
550
- messageTs,
660
+ messageTs: postedTs,
551
661
  });
552
662
  }
553
663
  console.log(`[Slack] Sent initial progress for task ${task.id.slice(0, 8)}`);
554
664
  }
665
+ if (postedTs) {
666
+ setSlackMessageTracking(task.id, { slackProgressMessageTs: postedTs });
667
+ }
555
668
  } catch (error) {
556
669
  // If send fails, clear markers so we can retry
557
670
  sentProgress.delete(task.id);
@@ -42,4 +42,17 @@ describe("agents list model display", () => {
42
42
  providerId: "openrouter",
43
43
  });
44
44
  });
45
+
46
+ test("presents latest Anthropic direct model ids as readable labels", () => {
47
+ expect(getAgentModelPresentation("claude-fable-5")).toMatchObject({
48
+ label: "Claude Fable 5",
49
+ provider: "Anthropic",
50
+ providerId: "anthropic",
51
+ });
52
+ expect(getAgentModelPresentation("claude-mythos-5")).toMatchObject({
53
+ label: "Claude Mythos 5",
54
+ provider: "Anthropic",
55
+ providerId: "anthropic",
56
+ });
57
+ });
45
58
  });
@@ -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();
@@ -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);
@@ -1012,6 +1012,25 @@ describe("Schedule CRUD", () => {
1012
1012
  expect(body.task.id).toBeDefined();
1013
1013
  });
1014
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
+
1015
1034
  test("POST /api/schedules/:id/run — disabled schedule returns 400", async () => {
1016
1035
  // Disable the schedule first
1017
1036
  await put(`/api/schedules/${scheduleId}`, {
@@ -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
  ],