@vellumai/assistant 0.12.5-dev.202609281715.b73f696 → 0.12.5-dev.202609281822.fb70dee

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 (26) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  3. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  4. package/src/__tests__/conversation-pairing.test.ts +112 -0
  5. package/src/__tests__/emit-signal-routing-intent.test.ts +159 -0
  6. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  7. package/src/__tests__/llm-schema.test.ts +1 -1
  8. package/src/__tests__/pricing.test.ts +11 -0
  9. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  10. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  11. package/src/notifications/README.md +41 -3
  12. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  13. package/src/notifications/__tests__/broadcaster.test.ts +299 -4
  14. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  15. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  16. package/src/notifications/assistant-initiated-thread.ts +101 -0
  17. package/src/notifications/broadcaster.ts +71 -12
  18. package/src/notifications/completion-policy.ts +7 -1
  19. package/src/notifications/conversation-pairing.ts +15 -70
  20. package/src/notifications/emit-signal.ts +34 -3
  21. package/src/plugin-api/vision-support.test.ts +9 -9
  22. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  23. package/src/providers/model-catalog.ts +3 -17
  24. package/src/util/pricing.ts +25 -0
  25. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  26. package/src/workspace/migrations/registry.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.5-dev.202609281715.b73f696",
3
+ "version": "0.12.5-dev.202609281822.fb70dee",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -40,7 +40,7 @@ describe("GET /v1/config profile vision enrichment", () => {
40
40
  seedProfiles({
41
41
  "test-no-vision": {
42
42
  provider: "fireworks",
43
- model: "accounts/fireworks/models/glm-5p2",
43
+ model: "accounts/fireworks/models/glm-5p3",
44
44
  },
45
45
  });
46
46
 
@@ -171,7 +171,7 @@ describe("config-validation-reset notice sentinel", () => {
171
171
  profiles: {
172
172
  "glm-default": {
173
173
  provider: "vellum",
174
- model: "accounts/fireworks/models/glm-5p2",
174
+ model: "accounts/fireworks/models/glm-5p3",
175
175
  },
176
176
  },
177
177
  activeProfile: "glm-default",
@@ -193,7 +193,7 @@ describe("config-validation-reset notice sentinel", () => {
193
193
  expect(config.llm.activeProfile).toBe("glm-default");
194
194
  expect(config.llm.callSites.mainAgent?.profile).toBe("glm-default");
195
195
  expect(config.llm.profiles["glm-default"]?.model).toBe(
196
- "accounts/fireworks/models/glm-5p2",
196
+ "accounts/fireworks/models/glm-5p3",
197
197
  );
198
198
  });
199
199
 
@@ -116,6 +116,7 @@ mock.module("../config/assistant-initiated-threads-gate.js", () => ({
116
116
  isAssistantInitiatedThreadsEnabled: () => assistantInitiatedThreadsEnabled,
117
117
  }));
118
118
 
119
+ import { resolveAssistantInitiatedThread } from "../notifications/assistant-initiated-thread.js";
119
120
  import { pairDeliveryWithConversation } from "../notifications/conversation-pairing.js";
120
121
  import type { NotificationSignal } from "../notifications/signal.js";
121
122
  import type {
@@ -1478,6 +1479,117 @@ describe("pairDeliveryWithConversation", () => {
1478
1479
  ...overrides,
1479
1480
  });
1480
1481
 
1482
+ test("a prepared resolution is reused even if the source changes before pairing", async () => {
1483
+ assistantInitiatedThreadsEnabled = true;
1484
+ const signal = share({
1485
+ conversationMetadata: { groupId: "group-123" },
1486
+ });
1487
+ const original = structuredClone(signal);
1488
+ const assistantInitiatedThread = resolveAssistantInitiatedThread(signal);
1489
+ getConversationMock.mockClear();
1490
+ mockExistingConversations[signal.sourceContextId] = {
1491
+ id: signal.sourceContextId,
1492
+ source: "user",
1493
+ title: null,
1494
+ conversationType: "standard",
1495
+ };
1496
+
1497
+ const result = await pairDeliveryWithConversation(
1498
+ signal,
1499
+ "vellum",
1500
+ makeCopy(),
1501
+ { assistantInitiatedThread },
1502
+ );
1503
+
1504
+ expect(result.createdNewConversation).toBe(true);
1505
+ expect(result.messageId).toBe("msg-001");
1506
+ expect(createConversationMock).toHaveBeenCalledWith(
1507
+ expect.objectContaining({
1508
+ source: "assistant_initiated",
1509
+ groupId: "group-123",
1510
+ }),
1511
+ );
1512
+ expect(getConversationMock).not.toHaveBeenCalled();
1513
+ expect(signal).toEqual(original);
1514
+ });
1515
+
1516
+ test("an ineligible prepared resolution retains passive pairing when the flag changes", async () => {
1517
+ const signal = share();
1518
+ const assistantInitiatedThread = resolveAssistantInitiatedThread(signal);
1519
+ assistantInitiatedThreadsEnabled = true;
1520
+ mockExistingConversations[signal.sourceContextId] = {
1521
+ id: signal.sourceContextId,
1522
+ source: "heartbeat",
1523
+ title: null,
1524
+ conversationType: "background",
1525
+ };
1526
+
1527
+ const result = await pairDeliveryWithConversation(
1528
+ signal,
1529
+ "vellum",
1530
+ makeCopy(),
1531
+ { assistantInitiatedThread },
1532
+ );
1533
+
1534
+ expect(result.createdNewConversation).toBe(false);
1535
+ expect(result.conversationId).toBe(signal.sourceContextId);
1536
+ expect(createConversationMock).not.toHaveBeenCalled();
1537
+ expect(addMessageMock).toHaveBeenCalledTimes(1);
1538
+ });
1539
+
1540
+ test.each(["slack", "telegram", "discord"] as const)(
1541
+ "%s retains the producer metadata despite a prepared vellum resolution",
1542
+ async (channel) => {
1543
+ assistantInitiatedThreadsEnabled = true;
1544
+ const signal = share({
1545
+ conversationMetadata: { groupId: "group-123" },
1546
+ });
1547
+ const original = structuredClone(signal);
1548
+ const assistantInitiatedThread =
1549
+ resolveAssistantInitiatedThread(signal);
1550
+ getConversationMock.mockClear();
1551
+
1552
+ const result = await pairDeliveryWithConversation(
1553
+ signal,
1554
+ channel,
1555
+ makeCopy(),
1556
+ { assistantInitiatedThread },
1557
+ );
1558
+
1559
+ expect(result.createdNewConversation).toBe(true);
1560
+ expect(result.strategy).toBe("continue_existing_conversation");
1561
+ expect(createConversationMock).toHaveBeenCalledWith(
1562
+ expect.objectContaining({
1563
+ conversationType: "background",
1564
+ source: "notification",
1565
+ groupId: "group-123",
1566
+ }),
1567
+ );
1568
+ expect(addMessageMock).not.toHaveBeenCalled();
1569
+ expect(getConversationMock).not.toHaveBeenCalled();
1570
+ expect(signal).toEqual(original);
1571
+ },
1572
+ );
1573
+
1574
+ test("platform delivery does not pair a prepared vellum resolution", async () => {
1575
+ assistantInitiatedThreadsEnabled = true;
1576
+ const signal = share();
1577
+ const assistantInitiatedThread = resolveAssistantInitiatedThread(signal);
1578
+
1579
+ const result = await pairDeliveryWithConversation(
1580
+ signal,
1581
+ "platform",
1582
+ makeCopy(),
1583
+ { assistantInitiatedThread },
1584
+ );
1585
+
1586
+ expect(result.strategy).toBe("push_only");
1587
+ expect(result.createdNewConversation).toBe(false);
1588
+ expect(result.conversationId).toBeNull();
1589
+ expect(createConversationMock).not.toHaveBeenCalled();
1590
+ expect(addMessageMock).not.toHaveBeenCalled();
1591
+ });
1592
+
1481
1593
  test("flag on: a share from a background run materializes a section thread", async () => {
1482
1594
  assistantInitiatedThreadsEnabled = true;
1483
1595
  mockExistingConversations["conv-heartbeat"] = {
@@ -1,8 +1,26 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { saveRawConfig } from "../config/loader.js";
4
+ import type { NotificationSignal } from "../notifications/signal.js";
4
5
  import type { NotificationChannel } from "../notifications/types.js";
5
6
 
7
+ let assistantInitiatedCandidate = false;
8
+ const resolveAssistantInitiatedThreadMock = mock(
9
+ (signal: NotificationSignal) => ({
10
+ isCandidate: assistantInitiatedCandidate,
11
+ vellumSignal: assistantInitiatedCandidate
12
+ ? {
13
+ ...signal,
14
+ requiresConversation: true,
15
+ conversationMetadata: { source: "assistant_initiated" },
16
+ }
17
+ : signal,
18
+ }),
19
+ );
20
+ mock.module("../notifications/assistant-initiated-thread.js", () => ({
21
+ resolveAssistantInitiatedThread: resolveAssistantInitiatedThreadMock,
22
+ }));
23
+
6
24
  const evaluateSignalMock = mock();
7
25
  const enforceRoutingIntentMock = mock();
8
26
  const updateDecisionMock = mock();
@@ -113,6 +131,8 @@ import {
113
131
 
114
132
  beforeEach(() => {
115
133
  saveRawConfig({});
134
+ assistantInitiatedCandidate = false;
135
+ resolveAssistantInitiatedThreadMock.mockClear();
116
136
  writeHomeFeedItemForSignalMock.mockClear();
117
137
  evaluateSignalMock.mockReset();
118
138
  enforceRoutingIntentMock.mockReset();
@@ -1020,3 +1040,142 @@ describe("classifyNotificationReceipt", () => {
1020
1040
  ).toBe("unknown");
1021
1041
  });
1022
1042
  });
1043
+
1044
+ describe("emitNotificationSignal From me push defaults", () => {
1045
+ const params = {
1046
+ sourceEventName: "assistant.share",
1047
+ sourceChannel: "assistant_tool" as const,
1048
+ sourceContextId: "conv-background",
1049
+ attentionHints: {
1050
+ urgency: "low" as const,
1051
+ requiresAction: false,
1052
+ isAsyncBackground: true,
1053
+ visibleInSourceNow: false,
1054
+ },
1055
+ };
1056
+ const decision = (selectedChannels: NotificationChannel[] = ["vellum"]) => ({
1057
+ shouldNotify: true,
1058
+ selectedChannels,
1059
+ reasoningSummary: "Assistant-authored share",
1060
+ renderedCopy: { vellum: { title: "An update", body: "A useful result." } },
1061
+ dedupeKey: "share-1",
1062
+ confidence: 1,
1063
+ fallbackUsed: false,
1064
+ persistedDecisionId: "decision-share",
1065
+ });
1066
+
1067
+ beforeEach(() => {
1068
+ assistantInitiatedCandidate = true;
1069
+ evaluateSignalMock.mockResolvedValue(decision());
1070
+ });
1071
+
1072
+ test("adds configured platform after routing and persists the final decision", async () => {
1073
+ const result = await emitNotificationSignal(params);
1074
+ expect(result.selectedChannels).toEqual(["vellum", "platform"]);
1075
+ expect(resolveAssistantInitiatedThreadMock).toHaveBeenCalledTimes(1);
1076
+ expect(updateDecisionMock).toHaveBeenCalledWith(
1077
+ "decision-share",
1078
+ expect.objectContaining({
1079
+ selectedChannels: ["vellum", "platform"],
1080
+ }),
1081
+ );
1082
+ expect(dispatchDecisionMock.mock.calls[0]?.[0]).toMatchObject({
1083
+ attentionHints: { urgency: "low" },
1084
+ });
1085
+ expect(dispatchDecisionMock.mock.calls[0]?.[3]).toMatchObject({
1086
+ assistantInitiatedThread: {
1087
+ platformAdded: true,
1088
+ resolution: {
1089
+ isCandidate: true,
1090
+ vellumSignal: {
1091
+ requiresConversation: true,
1092
+ conversationMetadata: { source: "assistant_initiated" },
1093
+ },
1094
+ },
1095
+ },
1096
+ });
1097
+ });
1098
+
1099
+ test.each([
1100
+ "quiet",
1101
+ "unconfigured",
1102
+ "noncandidate",
1103
+ "suppressed",
1104
+ "single_channel",
1105
+ "external_only",
1106
+ "vellum_allowlist",
1107
+ ])("respects %s policy without adding platform", async (scenario) => {
1108
+ assistantInitiatedCandidate = scenario !== "noncandidate";
1109
+ isPlatformClientConfiguredMock.mockResolvedValue(
1110
+ scenario !== "unconfigured",
1111
+ );
1112
+ evaluateSignalMock.mockResolvedValue({
1113
+ ...decision(scenario === "external_only" ? ["telegram"] : ["vellum"]),
1114
+ shouldNotify: scenario !== "suppressed",
1115
+ });
1116
+ const result = await emitNotificationSignal({
1117
+ ...params,
1118
+ routingIntent:
1119
+ scenario === "single_channel" ? "single_channel" : undefined,
1120
+ contextPayload: {
1121
+ quiet: scenario === "quiet",
1122
+ ...(scenario === "vellum_allowlist"
1123
+ ? { channelAllowlist: ["vellum"] }
1124
+ : {}),
1125
+ },
1126
+ });
1127
+ expect(result.selectedChannels).not.toContain("platform");
1128
+ expect(dispatchDecisionMock.mock.calls[0]?.[3]).toMatchObject({
1129
+ assistantInitiatedThread: { platformAdded: false },
1130
+ });
1131
+ });
1132
+
1133
+ test("keeps existing platform and external selections independent of this feature", async () => {
1134
+ evaluateSignalMock.mockResolvedValue(
1135
+ decision(["vellum", "platform", "telegram"]),
1136
+ );
1137
+ const result = await emitNotificationSignal({
1138
+ ...params,
1139
+ contextPayload: { quiet: true },
1140
+ });
1141
+ expect(result.selectedChannels).toEqual(["vellum", "platform", "telegram"]);
1142
+ expect(isPlatformClientConfiguredMock).not.toHaveBeenCalled();
1143
+ expect(dispatchDecisionMock.mock.calls[0]?.[3]).toMatchObject({
1144
+ assistantInitiatedThread: { platformAdded: false },
1145
+ });
1146
+ });
1147
+
1148
+ test("probes an unbound platform once across urgency and From me policy", async () => {
1149
+ isPlatformClientConfiguredMock.mockResolvedValue(false);
1150
+ const result = await emitNotificationSignal({
1151
+ ...params,
1152
+ attentionHints: { ...params.attentionHints, urgency: "high" },
1153
+ });
1154
+ expect(result.selectedChannels).toEqual(["vellum"]);
1155
+ expect(isPlatformClientConfiguredMock).toHaveBeenCalledTimes(1);
1156
+ });
1157
+
1158
+ test("preserves preferred external selections while adding the platform default", async () => {
1159
+ evaluateSignalMock.mockResolvedValue(decision(["vellum", "telegram"]));
1160
+ const result = await emitNotificationSignal(params);
1161
+ expect(result.selectedChannels).toEqual(["vellum", "telegram", "platform"]);
1162
+ });
1163
+
1164
+ test.each(["deduplicated", "source_active"])(
1165
+ "does not resolve candidates for %s signals",
1166
+ async (scenario) => {
1167
+ if (scenario === "deduplicated") {
1168
+ createEventMock.mockReturnValue(null);
1169
+ }
1170
+ await emitNotificationSignal({
1171
+ ...params,
1172
+ attentionHints: {
1173
+ ...params.attentionHints,
1174
+ visibleInSourceNow: scenario === "source_active",
1175
+ },
1176
+ });
1177
+ expect(resolveAssistantInitiatedThreadMock).not.toHaveBeenCalled();
1178
+ expect(dispatchDecisionMock).not.toHaveBeenCalled();
1179
+ },
1180
+ );
1181
+ });
@@ -390,9 +390,9 @@ describe("LLM catalog parity: daemon vs client", () => {
390
390
  });
391
391
  });
392
392
 
393
- test("Fireworks catalog includes GLM 5.2", () => {
393
+ test("Fireworks catalog includes GLM 5.3", () => {
394
394
  expect(
395
- isModelInCatalog("fireworks", "accounts/fireworks/models/glm-5p2"),
395
+ isModelInCatalog("fireworks", "accounts/fireworks/models/glm-5p3"),
396
396
  ).toBe(true);
397
397
 
398
398
  const fireworks = PROVIDER_CATALOG.find(
@@ -400,10 +400,10 @@ describe("LLM catalog parity: daemon vs client", () => {
400
400
  );
401
401
  expect(
402
402
  fireworks?.models.find(
403
- (model) => model.id === "accounts/fireworks/models/glm-5p2",
403
+ (model) => model.id === "accounts/fireworks/models/glm-5p3",
404
404
  ),
405
405
  ).toMatchObject({
406
- displayName: "GLM 5.2",
406
+ displayName: "GLM 5.3",
407
407
  contextWindowTokens: 1040000,
408
408
  maxOutputTokens: 131072,
409
409
  supportsToolUse: true,
@@ -198,7 +198,7 @@ describe("LLMSchema", () => {
198
198
  profiles: {
199
199
  "glm-default": {
200
200
  provider: "vellum",
201
- model: "accounts/fireworks/models/glm-5p2",
201
+ model: "accounts/fireworks/models/glm-5p3",
202
202
  },
203
203
  },
204
204
  activeProfile: "glm-default",
@@ -10,6 +10,17 @@ import {
10
10
  } from "../util/pricing.js";
11
11
 
12
12
  describe("resolvePricing", () => {
13
+ test.each([
14
+ ["accounts/fireworks/models/glm-5p2", 1.4 + 4.4],
15
+ ["accounts/fireworks/models/kimi-k2p6", 0.95 + 4.0],
16
+ ["accounts/fireworks/models/deepseek-v4-pro-0813", 1.32 + 3.96],
17
+ ["accounts/fireworks/models/deepseek-v4-flash-0731", 0.14 + 0.28],
18
+ ])("prices stored usage for retired Fireworks model %s", (model, cost) => {
19
+ const result = resolvePricing("fireworks", model, 1_000_000, 1_000_000);
20
+ expect(result.pricingStatus).toBe("priced");
21
+ expect(result.estimatedCostUsd).toBeCloseTo(cost, 10);
22
+ });
23
+
13
24
  describe("Anthropic models", () => {
14
25
  test("returns priced for claude-opus-4-6", () => {
15
26
  const result = resolvePricing(
@@ -100,7 +100,7 @@ describe("133-collapse-provider-connections migration", () => {
100
100
  source: "user",
101
101
  provider: "fireworks",
102
102
  provider_connection: "vellum",
103
- model: "accounts/fireworks/models/glm-5p2",
103
+ model: "accounts/fireworks/models/minimax-m3",
104
104
  },
105
105
  },
106
106
  },
@@ -257,7 +257,7 @@ describe("133-collapse-provider-connections migration", () => {
257
257
  encoded: {
258
258
  source: "user",
259
259
  provider: "vellum",
260
- model: "fireworks/accounts/fireworks/models/glm-5p2",
260
+ model: "fireworks/accounts/fireworks/models/minimax-m3",
261
261
  },
262
262
  },
263
263
  },
@@ -272,7 +272,7 @@ describe("133-collapse-provider-connections migration", () => {
272
272
  expect(llm.profiles.encoded).toEqual({
273
273
  source: "user",
274
274
  provider: "vellum",
275
- model: "accounts/fireworks/models/glm-5p2",
275
+ model: "accounts/fireworks/models/minimax-m3",
276
276
  });
277
277
  });
278
278
 
@@ -0,0 +1,233 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { Database } from "bun:sqlite";
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { repairRetiredFireworksGlm5p2ModelIdMigration } from "../workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.js";
14
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
15
+ import { assertNotLiveDb } from "./assert-not-live-db.js";
16
+
17
+ const STALE = "accounts/fireworks/models/glm-5p2";
18
+ const REPLACEMENT = "accounts/fireworks/models/glm-5p3";
19
+
20
+ let workspaceDir: string;
21
+
22
+ function freshWorkspace(): void {
23
+ workspaceDir = join(
24
+ tmpdir(),
25
+ `vellum-migration-161-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
26
+ );
27
+ mkdirSync(workspaceDir, { recursive: true });
28
+ }
29
+
30
+ function writeConfig(data: Record<string, unknown>): void {
31
+ writeFileSync(
32
+ join(workspaceDir, "config.json"),
33
+ JSON.stringify(data, null, 2) + "\n",
34
+ );
35
+ }
36
+
37
+ function readConfig(): Record<string, unknown> {
38
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
39
+ }
40
+
41
+ function seedRows(rows: Array<{ name: string; provider: string }>): void {
42
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
43
+ const db = new Database(join(workspaceDir, "data", "db", "assistant.db"));
44
+ db.run(`CREATE TABLE IF NOT EXISTS provider_connections (
45
+ name TEXT PRIMARY KEY,
46
+ provider TEXT NOT NULL,
47
+ auth TEXT NOT NULL,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL
50
+ )`);
51
+ for (const row of rows) {
52
+ db.query(
53
+ `INSERT INTO provider_connections (name, provider, auth, created_at, updated_at) VALUES (?, ?, '{"type":"api_key"}', 1, 1)`,
54
+ ).run(row.name, row.provider);
55
+ }
56
+ db.close();
57
+ }
58
+
59
+ beforeEach(() => {
60
+ freshWorkspace();
61
+ });
62
+
63
+ afterEach(() => {
64
+ if (existsSync(workspaceDir)) {
65
+ assertNotLiveDb(workspaceDir);
66
+ rmSync(workspaceDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ describe("161-repair-retired-fireworks-glm-5p2-model-id migration", () => {
71
+ test("has correct migration id and is registered", () => {
72
+ expect(repairRetiredFireworksGlm5p2ModelIdMigration.id).toBe(
73
+ "161-repair-retired-fireworks-glm-5p2-model-id",
74
+ );
75
+ expect(WORKSPACE_MIGRATIONS.map((m) => m.id)).toContain(
76
+ "161-repair-retired-fireworks-glm-5p2-model-id",
77
+ );
78
+ });
79
+
80
+ test("repairs the stale ID in default, call sites, and profiles", () => {
81
+ writeConfig({
82
+ llm: {
83
+ default: { provider: "fireworks", model: STALE },
84
+ callSites: {
85
+ recall: { model: STALE, maxTokens: 4096 },
86
+ heartbeat: { model: `${STALE}-tuned` },
87
+ malformed: STALE,
88
+ },
89
+ profiles: {
90
+ "cost-optimized": { provider: "vellum", model: STALE },
91
+ legacy: { model: STALE },
92
+ },
93
+ },
94
+ });
95
+
96
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
97
+
98
+ const llm = readConfig().llm as Record<string, any>;
99
+ expect(llm.default.model).toBe(REPLACEMENT);
100
+ expect(llm.callSites.recall.model).toBe(REPLACEMENT);
101
+ expect(llm.callSites.recall.maxTokens).toBe(4096);
102
+ // Non-exact matches and malformed leaves are untouched.
103
+ expect(llm.callSites.heartbeat.model).toBe(`${STALE}-tuned`);
104
+ expect(llm.callSites.malformed).toBe(STALE);
105
+ // Managed profiles stamped provider "vellum" carry Fireworks model IDs.
106
+ expect(llm.profiles["cost-optimized"].model).toBe(REPLACEMENT);
107
+ expect(llm.profiles.legacy.model).toBe(REPLACEMENT);
108
+ });
109
+
110
+ test("repairs entry-bound fragments whose row kind is fireworks or vellum", () => {
111
+ seedRows([
112
+ { name: "my-fireworks", provider: "fireworks" },
113
+ { name: "managed-alt", provider: "vellum" },
114
+ ]);
115
+ writeConfig({
116
+ llm: {
117
+ profiles: {
118
+ bound: { provider: "my-fireworks", model: STALE },
119
+ managed: { provider: "managed-alt", model: STALE },
120
+ },
121
+ },
122
+ });
123
+
124
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
125
+
126
+ const llm = readConfig().llm as Record<string, any>;
127
+ expect(llm.profiles.bound.model).toBe(REPLACEMENT);
128
+ expect(llm.profiles.managed.model).toBe(REPLACEMENT);
129
+ });
130
+
131
+ test("leaves fragments with an explicit other provider untouched", () => {
132
+ seedRows([{ name: "byo-compat", provider: "openai-compatible" }]);
133
+ writeConfig({
134
+ llm: {
135
+ default: { provider: "openai-compatible", model: STALE },
136
+ profiles: {
137
+ byo: { provider: "openai-compatible", model: STALE },
138
+ // Entry-bound profile whose row kind is not repairable.
139
+ compatBound: { provider: "byo-compat", model: STALE },
140
+ // Entry name with no row: dangling, so nothing proves it is a
141
+ // fireworks-kind route.
142
+ dangling: { provider: "ghost", model: STALE },
143
+ },
144
+ },
145
+ });
146
+
147
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
148
+
149
+ const llm = readConfig().llm as Record<string, any>;
150
+ expect(llm.default.model).toBe(STALE);
151
+ expect(llm.profiles.byo.model).toBe(STALE);
152
+ expect(llm.profiles.compatBound.model).toBe(STALE);
153
+ expect(llm.profiles.dangling.model).toBe(STALE);
154
+ });
155
+
156
+ test("leaves entry-name providers untouched when no DB file exists", () => {
157
+ writeConfig({
158
+ llm: {
159
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
160
+ },
161
+ });
162
+
163
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
164
+
165
+ const llm = readConfig().llm as Record<string, any>;
166
+ expect(llm.profiles.bound.model).toBe(STALE);
167
+ });
168
+
169
+ test("throws when an entry-name provider needs rows and the DB is unreadable", () => {
170
+ // A DB file without the provider_connections table is unqueryable, so
171
+ // the run must fail (and retry later) instead of skipping the profile.
172
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
173
+ writeFileSync(join(workspaceDir, "data", "db", "assistant.db"), "");
174
+ writeConfig({
175
+ llm: {
176
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
177
+ },
178
+ });
179
+
180
+ expect(() =>
181
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir),
182
+ ).toThrow();
183
+ const llm = readConfig().llm as Record<string, any>;
184
+ expect(llm.profiles.bound.model).toBe(STALE);
185
+
186
+ // Vendor and absent providers never need the rows, so the same broken
187
+ // DB does not block their repair.
188
+ writeConfig({
189
+ llm: {
190
+ default: { provider: "fireworks", model: STALE },
191
+ profiles: { legacy: { model: STALE } },
192
+ },
193
+ });
194
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
195
+ const repaired = readConfig().llm as Record<string, any>;
196
+ expect(repaired.default.model).toBe(REPLACEMENT);
197
+ expect(repaired.profiles.legacy.model).toBe(REPLACEMENT);
198
+ });
199
+
200
+ test("is idempotent and a no-op without the stale ID", () => {
201
+ writeConfig({
202
+ llm: {
203
+ default: { provider: "fireworks", model: STALE },
204
+ profiles: { fine: { provider: "fireworks", model: REPLACEMENT } },
205
+ },
206
+ });
207
+
208
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
209
+ const first = readConfig();
210
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
211
+ expect(readConfig()).toEqual(first);
212
+
213
+ const llm = first.llm as Record<string, any>;
214
+ expect(llm.default.model).toBe(REPLACEMENT);
215
+ expect(llm.profiles.fine.model).toBe(REPLACEMENT);
216
+ });
217
+
218
+ test("handles missing config, missing llm block, and invalid JSON", () => {
219
+ // No config.json at all.
220
+ expect(() =>
221
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir),
222
+ ).not.toThrow();
223
+
224
+ writeConfig({ theme: "dark" });
225
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir);
226
+ expect(readConfig()).toEqual({ theme: "dark" });
227
+
228
+ writeFileSync(join(workspaceDir, "config.json"), "{not json");
229
+ expect(() =>
230
+ repairRetiredFireworksGlm5p2ModelIdMigration.run(workspaceDir),
231
+ ).not.toThrow();
232
+ });
233
+ });