@vellumai/assistant 0.4.52 → 0.4.53

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 (205) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/architecture/keychain-broker.md +6 -20
  3. package/docs/architecture/memory.md +3 -3
  4. package/package.json +1 -1
  5. package/src/__tests__/approval-cascade.test.ts +3 -1
  6. package/src/__tests__/approval-routes-http.test.ts +0 -1
  7. package/src/__tests__/asset-materialize-tool.test.ts +0 -1
  8. package/src/__tests__/asset-search-tool.test.ts +0 -1
  9. package/src/__tests__/assistant-events-sse-hardening.test.ts +0 -1
  10. package/src/__tests__/attachments-store.test.ts +0 -1
  11. package/src/__tests__/avatar-e2e.test.ts +6 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +3 -0
  13. package/src/__tests__/btw-routes.test.ts +39 -0
  14. package/src/__tests__/call-controller.test.ts +0 -1
  15. package/src/__tests__/call-domain.test.ts +1 -0
  16. package/src/__tests__/call-routes-http.test.ts +1 -2
  17. package/src/__tests__/canonical-guardian-store.test.ts +33 -2
  18. package/src/__tests__/channel-readiness-service.test.ts +1 -0
  19. package/src/__tests__/claude-code-skill-regression.test.ts +6 -2
  20. package/src/__tests__/claude-code-tool-profiles.test.ts +7 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +1 -2
  22. package/src/__tests__/config-schema.test.ts +6 -37
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +0 -1
  24. package/src/__tests__/credential-broker-server-use.test.ts +16 -16
  25. package/src/__tests__/credential-security-invariants.test.ts +14 -0
  26. package/src/__tests__/credential-vault-unit.test.ts +4 -4
  27. package/src/__tests__/error-handler-friendly-messages.test.ts +4 -5
  28. package/src/__tests__/gateway-only-enforcement.test.ts +0 -2
  29. package/src/__tests__/host-shell-tool.test.ts +0 -1
  30. package/src/__tests__/http-user-message-parity.test.ts +19 -0
  31. package/src/__tests__/list-messages-attachments.test.ts +0 -1
  32. package/src/__tests__/log-export-workspace.test.ts +233 -0
  33. package/src/__tests__/managed-proxy-context.test.ts +1 -1
  34. package/src/__tests__/managed-skill-lifecycle.test.ts +0 -1
  35. package/src/__tests__/media-generate-image.test.ts +7 -2
  36. package/src/__tests__/media-reuse-story.e2e.test.ts +0 -1
  37. package/src/__tests__/memory-regressions.test.ts +0 -1
  38. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  39. package/src/__tests__/migration-export-http.test.ts +0 -1
  40. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  41. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  42. package/src/__tests__/migration-validate-http.test.ts +0 -1
  43. package/src/__tests__/notification-schedule-dedup.test.ts +237 -0
  44. package/src/__tests__/oauth-cli.test.ts +1 -10
  45. package/src/__tests__/oauth-store.test.ts +3 -5
  46. package/src/__tests__/oauth2-gateway-transport.test.ts +5 -4
  47. package/src/__tests__/onboarding-starter-tasks.test.ts +1 -1
  48. package/src/__tests__/onboarding-template-contract.test.ts +1 -2
  49. package/src/__tests__/pricing.test.ts +0 -11
  50. package/src/__tests__/provider-commit-message-generator.test.ts +21 -14
  51. package/src/__tests__/provider-fail-open-selection.test.ts +9 -8
  52. package/src/__tests__/provider-managed-proxy-integration.test.ts +27 -24
  53. package/src/__tests__/provider-registry-ollama.test.ts +8 -2
  54. package/src/__tests__/recording-handler.test.ts +0 -1
  55. package/src/__tests__/relay-server.test.ts +0 -1
  56. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  57. package/src/__tests__/runtime-events-sse-parity.test.ts +0 -1
  58. package/src/__tests__/runtime-events-sse.test.ts +0 -1
  59. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -1
  60. package/src/__tests__/secret-scanner-executor.test.ts +0 -1
  61. package/src/__tests__/send-endpoint-busy.test.ts +0 -1
  62. package/src/__tests__/session-abort-tool-results.test.ts +3 -1
  63. package/src/__tests__/session-agent-loop-overflow.test.ts +1012 -838
  64. package/src/__tests__/session-agent-loop.test.ts +2 -2
  65. package/src/__tests__/session-confirmation-signals.test.ts +3 -1
  66. package/src/__tests__/session-error.test.ts +5 -4
  67. package/src/__tests__/session-history-web-search.test.ts +34 -9
  68. package/src/__tests__/session-pre-run-repair.test.ts +3 -1
  69. package/src/__tests__/session-provider-retry-repair.test.ts +31 -26
  70. package/src/__tests__/session-queue.test.ts +3 -1
  71. package/src/__tests__/session-runtime-assembly.test.ts +118 -0
  72. package/src/__tests__/session-slash-known.test.ts +31 -13
  73. package/src/__tests__/session-slash-queue.test.ts +3 -1
  74. package/src/__tests__/session-slash-unknown.test.ts +3 -1
  75. package/src/__tests__/session-workspace-cache-state.test.ts +3 -1
  76. package/src/__tests__/session-workspace-injection.test.ts +3 -1
  77. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -1
  78. package/src/__tests__/shell-tool-proxy-mode.test.ts +0 -1
  79. package/src/__tests__/skill-script-runner-sandbox.test.ts +0 -1
  80. package/src/__tests__/skillssh-registry.test.ts +21 -0
  81. package/src/__tests__/slack-share-routes.test.ts +1 -1
  82. package/src/__tests__/swarm-recursion.test.ts +5 -1
  83. package/src/__tests__/swarm-session-integration.test.ts +25 -14
  84. package/src/__tests__/swarm-tool.test.ts +5 -2
  85. package/src/__tests__/telegram-bot-username-resolution.test.ts +2 -4
  86. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1521 -0
  87. package/src/__tests__/tool-execution-abort-cleanup.test.ts +0 -1
  88. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  90. package/src/__tests__/tool-executor.test.ts +0 -1
  91. package/src/__tests__/trust-store.test.ts +5 -1
  92. package/src/__tests__/twilio-routes.test.ts +2 -2
  93. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  94. package/src/__tests__/voice-quality.test.ts +2 -1
  95. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  96. package/src/__tests__/web-search.test.ts +1 -1
  97. package/src/agent/loop.ts +17 -1
  98. package/src/bundler/app-bundler.ts +40 -24
  99. package/src/calls/call-controller.ts +16 -0
  100. package/src/calls/relay-server.ts +29 -13
  101. package/src/calls/voice-control-protocol.ts +1 -0
  102. package/src/calls/voice-quality.ts +1 -1
  103. package/src/calls/voice-session-bridge.ts +9 -3
  104. package/src/channels/types.ts +16 -0
  105. package/src/cli/commands/bash.ts +173 -0
  106. package/src/cli/commands/doctor.ts +5 -23
  107. package/src/cli/commands/oauth/connections.ts +4 -2
  108. package/src/cli/commands/oauth/providers.ts +1 -13
  109. package/src/cli/program.ts +2 -0
  110. package/src/cli/reference.ts +1 -0
  111. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -1
  112. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +3 -5
  113. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -3
  114. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +1 -1
  115. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +5 -6
  116. package/src/config/feature-flag-registry.json +8 -0
  117. package/src/config/loader.ts +7 -135
  118. package/src/config/schema.ts +0 -6
  119. package/src/config/schemas/channels.ts +1 -0
  120. package/src/config/schemas/elevenlabs.ts +2 -2
  121. package/src/contacts/contact-store.ts +21 -25
  122. package/src/contacts/contacts-write.ts +6 -6
  123. package/src/contacts/types.ts +2 -0
  124. package/src/context/token-estimator.ts +35 -2
  125. package/src/context/window-manager.ts +16 -2
  126. package/src/daemon/config-watcher.ts +24 -6
  127. package/src/daemon/context-overflow-reducer.ts +13 -2
  128. package/src/daemon/handlers/config-ingress.ts +25 -8
  129. package/src/daemon/handlers/config-model.ts +21 -15
  130. package/src/daemon/handlers/config-telegram.ts +18 -6
  131. package/src/daemon/handlers/dictation.ts +0 -429
  132. package/src/daemon/handlers/skills.ts +1 -200
  133. package/src/daemon/lifecycle.ts +8 -5
  134. package/src/daemon/message-types/contacts.ts +2 -0
  135. package/src/daemon/message-types/integrations.ts +1 -0
  136. package/src/daemon/message-types/sessions.ts +2 -0
  137. package/src/daemon/parse-actual-tokens-from-error.test.ts +75 -0
  138. package/src/daemon/server.ts +23 -2
  139. package/src/daemon/session-agent-loop-handlers.ts +1 -1
  140. package/src/daemon/session-agent-loop.ts +27 -79
  141. package/src/daemon/session-error.ts +5 -4
  142. package/src/daemon/session-process.ts +17 -10
  143. package/src/daemon/session-runtime-assembly.ts +50 -0
  144. package/src/daemon/session-slash.ts +32 -20
  145. package/src/daemon/session.ts +1 -0
  146. package/src/events/domain-events.ts +1 -0
  147. package/src/media/app-icon-generator.ts +2 -1
  148. package/src/media/avatar-router.ts +3 -2
  149. package/src/memory/canonical-guardian-store.ts +25 -3
  150. package/src/memory/db-init.ts +12 -0
  151. package/src/memory/embedding-backend.ts +25 -16
  152. package/src/memory/migrations/158-channel-interaction-columns.ts +18 -0
  153. package/src/memory/migrations/159-drop-contact-interaction-columns.ts +16 -0
  154. package/src/memory/migrations/160-drop-loopback-port-column.ts +13 -0
  155. package/src/memory/migrations/index.ts +3 -0
  156. package/src/memory/retriever.test.ts +19 -12
  157. package/src/memory/schema/contacts.ts +2 -2
  158. package/src/memory/schema/oauth.ts +0 -1
  159. package/src/oauth/connect-orchestrator.ts +5 -3
  160. package/src/oauth/connect-types.ts +9 -2
  161. package/src/oauth/manual-token-connection.ts +9 -7
  162. package/src/oauth/oauth-store.ts +2 -8
  163. package/src/oauth/provider-behaviors.ts +10 -0
  164. package/src/oauth/seed-providers.ts +13 -5
  165. package/src/permissions/checker.ts +20 -1
  166. package/src/prompts/__tests__/build-cli-reference-section.test.ts +1 -1
  167. package/src/prompts/system-prompt.ts +2 -11
  168. package/src/prompts/templates/BOOTSTRAP.md +1 -3
  169. package/src/providers/anthropic/client.ts +16 -8
  170. package/src/providers/managed-proxy/constants.ts +1 -1
  171. package/src/providers/registry.ts +21 -15
  172. package/src/providers/types.ts +1 -1
  173. package/src/runtime/auth/route-policy.ts +4 -0
  174. package/src/runtime/channel-invite-transports/telegram.ts +12 -6
  175. package/src/runtime/channel-retry-sweep.ts +6 -0
  176. package/src/runtime/http-types.ts +1 -0
  177. package/src/runtime/middleware/error-handler.ts +1 -2
  178. package/src/runtime/routes/app-management-routes.ts +1 -0
  179. package/src/runtime/routes/btw-routes.ts +20 -1
  180. package/src/runtime/routes/conversation-routes.ts +32 -13
  181. package/src/runtime/routes/inbound-message-handler.ts +10 -2
  182. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  183. package/src/runtime/routes/inbound-stages/edit-intercept.ts +5 -5
  184. package/src/runtime/routes/integrations/slack/share.ts +5 -5
  185. package/src/runtime/routes/log-export-routes.ts +122 -10
  186. package/src/runtime/routes/session-query-routes.ts +3 -3
  187. package/src/runtime/routes/settings-routes.ts +53 -0
  188. package/src/runtime/routes/workspace-routes.ts +3 -0
  189. package/src/runtime/verification-templates.ts +1 -1
  190. package/src/security/oauth2.ts +4 -4
  191. package/src/security/secure-keys.ts +4 -4
  192. package/src/signals/bash.ts +157 -0
  193. package/src/skills/skillssh-registry.ts +6 -1
  194. package/src/swarm/backend-claude-code.ts +6 -6
  195. package/src/swarm/worker-backend.ts +1 -1
  196. package/src/swarm/worker-runner.ts +1 -1
  197. package/src/telegram/bot-username.ts +11 -0
  198. package/src/tools/claude-code/claude-code.ts +4 -4
  199. package/src/tools/credentials/broker.ts +7 -5
  200. package/src/tools/credentials/vault.ts +3 -2
  201. package/src/tools/network/__tests__/web-search.test.ts +18 -86
  202. package/src/tools/network/web-search.ts +9 -15
  203. package/src/util/platform.ts +7 -1
  204. package/src/util/pricing.ts +0 -1
  205. package/src/workspace/provider-commit-message-generator.ts +10 -6
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Regression test: recurring schedule notifications must not be
3
+ * deduplicated against prior firings of the same schedule.
4
+ *
5
+ * Before the fix, `schedule.complete` signals were emitted without a
6
+ * producer dedupeKey. The LLM decision engine would generate a stable
7
+ * key (e.g. `schedule:complete:<id>`) and `updateEventDedupeKey` would
8
+ * write it back to the event row. On the next firing, `checkDedupe`
9
+ * found the first row's stable key within the 1-hour window and
10
+ * silently blocked the notification.
11
+ *
12
+ * The fix: always supply a unique per-firing dedupeKey from the
13
+ * producer so `updateEventDedupeKey` is never called for schedule
14
+ * signals, and `checkDedupe` never finds a matching row.
15
+ */
16
+
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
21
+
22
+ const testDir = mkdtempSync(
23
+ join(tmpdir(), "notification-schedule-dedup-test-"),
24
+ );
25
+
26
+ mock.module("../util/platform.js", () => ({
27
+ getDataDir: () => testDir,
28
+ isMacOS: () => process.platform === "darwin",
29
+ isLinux: () => process.platform === "linux",
30
+ isWindows: () => process.platform === "win32",
31
+ getPidPath: () => join(testDir, "test.pid"),
32
+ getDbPath: () => join(testDir, "test.db"),
33
+ getLogPath: () => join(testDir, "test.log"),
34
+ ensureDataDir: () => {},
35
+ }));
36
+
37
+ mock.module("../util/logger.js", () => ({
38
+ getLogger: () =>
39
+ new Proxy({} as Record<string, unknown>, {
40
+ get: () => () => {},
41
+ }),
42
+ truncateForLog: (value: string) => value,
43
+ }));
44
+
45
+ import { getDb, initializeDb } from "../memory/db.js";
46
+ import { notificationEvents } from "../memory/schema.js";
47
+ import { runDeterministicChecks } from "../notifications/deterministic-checks.js";
48
+ import {
49
+ createEvent,
50
+ updateEventDedupeKey,
51
+ } from "../notifications/events-store.js";
52
+ import type { NotificationSignal } from "../notifications/signal.js";
53
+ import type { NotificationDecision } from "../notifications/types.js";
54
+
55
+ initializeDb();
56
+
57
+ afterAll(() => {
58
+ try {
59
+ rmSync(testDir, { recursive: true, force: true });
60
+ } catch {}
61
+ });
62
+
63
+ beforeEach(() => {
64
+ // Clear notification events between tests for isolation
65
+ getDb().delete(notificationEvents).run();
66
+ });
67
+
68
+ function makeSignal(
69
+ overrides?: Partial<NotificationSignal>,
70
+ ): NotificationSignal {
71
+ return {
72
+ signalId: `sig-${crypto.randomUUID()}`,
73
+ createdAt: Date.now(),
74
+ sourceChannel: "scheduler",
75
+ sourceSessionId: "schedule-123",
76
+ sourceEventName: "schedule.complete",
77
+ contextPayload: { scheduleId: "schedule-123", name: "Drink water" },
78
+ attentionHints: {
79
+ requiresAction: false,
80
+ urgency: "medium",
81
+ isAsyncBackground: true,
82
+ visibleInSourceNow: false,
83
+ },
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ function makeDecision(
89
+ overrides?: Partial<NotificationDecision>,
90
+ ): NotificationDecision {
91
+ return {
92
+ shouldNotify: true,
93
+ selectedChannels: ["vellum"],
94
+ reasoningSummary: "Schedule completed",
95
+ renderedCopy: {
96
+ vellum: { title: "Reminder", body: "Time to drink water" },
97
+ },
98
+ dedupeKey: "schedule:complete:schedule-123",
99
+ confidence: 0.9,
100
+ fallbackUsed: false,
101
+ ...overrides,
102
+ };
103
+ }
104
+
105
+ describe("recurring schedule notification dedup", () => {
106
+ test("second firing is blocked when LLM stable key is written to first event row (the bug)", async () => {
107
+ // Simulate the BROKEN behavior: producer sends no dedupeKey,
108
+ // LLM generates a stable key, and updateEventDedupeKey writes it
109
+ // to the first event row.
110
+
111
+ const stableKey = "schedule:complete:schedule-123";
112
+ const firstId = crypto.randomUUID();
113
+ const secondId = crypto.randomUUID();
114
+
115
+ // First firing: create event with null dedupeKey, then backfill with LLM key
116
+ const firstSignal = makeSignal({ signalId: firstId });
117
+ createEvent({
118
+ id: firstSignal.signalId,
119
+ sourceEventName: "schedule.complete",
120
+ sourceChannel: "scheduler",
121
+ sourceSessionId: "schedule-123",
122
+ attentionHints: firstSignal.attentionHints,
123
+ payload: firstSignal.contextPayload,
124
+ // No dedupeKey — this is the bug scenario
125
+ });
126
+ // LLM decision generates a stable key, pipeline writes it back
127
+ updateEventDedupeKey(firstSignal.signalId, stableKey);
128
+
129
+ // Second firing: new event, same schedule
130
+ const secondSignal = makeSignal({ signalId: secondId });
131
+ createEvent({
132
+ id: secondSignal.signalId,
133
+ sourceEventName: "schedule.complete",
134
+ sourceChannel: "scheduler",
135
+ sourceSessionId: "schedule-123",
136
+ attentionHints: secondSignal.attentionHints,
137
+ payload: secondSignal.contextPayload,
138
+ });
139
+
140
+ // LLM generates the same stable key for the second firing
141
+ const decision = makeDecision({ dedupeKey: stableKey });
142
+
143
+ const result = await runDeterministicChecks(secondSignal, decision, {
144
+ connectedChannels: ["vellum"],
145
+ });
146
+
147
+ // The second firing is BLOCKED — this is the bug
148
+ expect(result.passed).toBe(false);
149
+ expect(result.reason).toContain("Dedupe");
150
+ });
151
+
152
+ test("second firing passes when producer supplies unique per-firing dedupeKey (the fix)", async () => {
153
+ const stableKey = "schedule:complete:schedule-123";
154
+ const firstId = crypto.randomUUID();
155
+ const secondId = crypto.randomUUID();
156
+
157
+ // First firing: producer supplies a timestamped dedupeKey
158
+ const firstSignal = makeSignal({ signalId: firstId });
159
+ createEvent({
160
+ id: firstSignal.signalId,
161
+ sourceEventName: "schedule.complete",
162
+ sourceChannel: "scheduler",
163
+ sourceSessionId: "schedule-123",
164
+ attentionHints: firstSignal.attentionHints,
165
+ payload: firstSignal.contextPayload,
166
+ dedupeKey: `schedule:complete:schedule-123:${Date.now() - 60_000}`,
167
+ });
168
+ // updateEventDedupeKey is NOT called because params.dedupeKey is truthy
169
+
170
+ // Second firing: new event with its own unique timestamped key
171
+ const secondSignal = makeSignal({ signalId: secondId });
172
+ createEvent({
173
+ id: secondSignal.signalId,
174
+ sourceEventName: "schedule.complete",
175
+ sourceChannel: "scheduler",
176
+ sourceSessionId: "schedule-123",
177
+ attentionHints: secondSignal.attentionHints,
178
+ payload: secondSignal.contextPayload,
179
+ dedupeKey: `schedule:complete:schedule-123:${Date.now()}`,
180
+ });
181
+
182
+ // LLM still generates a stable key — but no row in the DB has it
183
+ const decision = makeDecision({ dedupeKey: stableKey });
184
+
185
+ const result = await runDeterministicChecks(secondSignal, decision, {
186
+ connectedChannels: ["vellum"],
187
+ });
188
+
189
+ // The second firing PASSES — the fix works
190
+ expect(result.passed).toBe(true);
191
+ });
192
+
193
+ test("notify mode with timestamped producer keys is not blocked", async () => {
194
+ const stableKey = "schedule:notify:schedule-123";
195
+ const firstId = crypto.randomUUID();
196
+ const secondId = crypto.randomUUID();
197
+
198
+ // First firing
199
+ const firstSignal = makeSignal({
200
+ signalId: firstId,
201
+ sourceEventName: "schedule.notify",
202
+ });
203
+ createEvent({
204
+ id: firstSignal.signalId,
205
+ sourceEventName: "schedule.notify",
206
+ sourceChannel: "scheduler",
207
+ sourceSessionId: "schedule-123",
208
+ attentionHints: firstSignal.attentionHints,
209
+ payload: firstSignal.contextPayload,
210
+ dedupeKey: `schedule:notify:schedule-123:${Date.now() - 60_000}`,
211
+ });
212
+
213
+ // Second firing
214
+ const secondSignal = makeSignal({
215
+ signalId: secondId,
216
+ sourceEventName: "schedule.notify",
217
+ });
218
+ createEvent({
219
+ id: secondSignal.signalId,
220
+ sourceEventName: "schedule.notify",
221
+ sourceChannel: "scheduler",
222
+ sourceSessionId: "schedule-123",
223
+ attentionHints: secondSignal.attentionHints,
224
+ payload: secondSignal.contextPayload,
225
+ dedupeKey: `schedule:notify:schedule-123:${Date.now()}`,
226
+ });
227
+
228
+ // LLM generates stable key — no matching row
229
+ const decision = makeDecision({ dedupeKey: stableKey });
230
+
231
+ const result = await runDeterministicChecks(secondSignal, decision, {
232
+ connectedChannels: ["vellum"],
233
+ });
234
+
235
+ expect(result.passed).toBe(true);
236
+ });
237
+ });
@@ -154,17 +154,8 @@ mock.module("../oauth/oauth-store.js", () => ({
154
154
 
155
155
  // Stub out transitive dependencies that token-manager would normally pull in
156
156
  mock.module("../security/secure-keys.js", () => ({
157
- getSecureKey: (account: string) => mockGetSecureKey(account),
158
- setSecureKey: () => true,
159
- getSecureKeyAsync: async () => undefined,
157
+ getSecureKeyAsync: async (account: string) => mockGetSecureKey(account),
160
158
  setSecureKeyAsync: async () => true,
161
- deleteSecureKey: (account: string) => {
162
- if (secureKeyStore.has(account)) {
163
- secureKeyStore.delete(account);
164
- return "deleted" as const;
165
- }
166
- return "not-found" as const;
167
- },
168
159
  deleteSecureKeyAsync: async (account: string) => {
169
160
  if (secureKeyStore.has(account)) {
170
161
  secureKeyStore.delete(account);
@@ -232,7 +232,7 @@ describe("provider operations", () => {
232
232
  baseUrl: "https://api.github.com",
233
233
  extraParams: { prompt: "consent" },
234
234
  callbackTransport: "loopback",
235
- loopbackPort: 8765,
235
+
236
236
  pingUrl: "https://api.github.com/user",
237
237
  },
238
238
  ]);
@@ -277,7 +277,7 @@ describe("provider operations", () => {
277
277
  baseUrl: "https://api.github.com/v2",
278
278
  extraParams: { prompt: "login" },
279
279
  callbackTransport: "gateway",
280
- loopbackPort: 9999,
280
+
281
281
  pingUrl: "https://api.github.com/user-v2",
282
282
  },
283
283
  ]);
@@ -300,7 +300,6 @@ describe("provider operations", () => {
300
300
  expect(row!.tokenEndpointAuthMethod).toBe("client_secret_basic");
301
301
  expect(JSON.parse(row!.extraParams!)).toEqual({ prompt: "login" });
302
302
  expect(row!.callbackTransport).toBe("gateway");
303
- expect(row!.loopbackPort).toBe(9999);
304
303
  expect(row!.pingUrl).toBe("https://api.github.com/user-v2");
305
304
  });
306
305
  });
@@ -315,7 +314,7 @@ describe("provider operations", () => {
315
314
  defaultScopes: ["repo"],
316
315
  scopePolicy: {},
317
316
  callbackTransport: "loopback",
318
- loopbackPort: 8765,
317
+
319
318
  },
320
319
  ]);
321
320
 
@@ -323,7 +322,6 @@ describe("provider operations", () => {
323
322
  expect(row).toBeDefined();
324
323
  expect(row!.providerKey).toBe("github");
325
324
  expect(row!.callbackTransport).toBe("loopback");
326
- expect(row!.loopbackPort).toBe(8765);
327
325
  });
328
326
 
329
327
  test("returns undefined for unknown keys", () => {
@@ -185,6 +185,7 @@ describe("OAuth2 gateway transport", () => {
185
185
  // The auth URL should contain the gateway redirect_uri, not a loopback one
186
186
  expect(capturedAuthUrl).toContain("redirect_uri=");
187
187
  expect(capturedAuthUrl).not.toContain("127.0.0.1");
188
+ expect(capturedAuthUrl).not.toMatch(/localhost:\d+/);
188
189
  expect(capturedAuthUrl).toContain(
189
190
  encodeURIComponent("https://gw.example.com"),
190
191
  );
@@ -212,9 +213,9 @@ describe("OAuth2 gateway transport", () => {
212
213
  // Give the loopback server time to start
213
214
  await new Promise((r) => setTimeout(r, 50));
214
215
 
215
- // Auth URL should use a 127.0.0.1 redirect_uri
216
+ // Auth URL should use a localhost redirect_uri
216
217
  expect(capturedAuthUrl).toContain("redirect_uri=");
217
- expect(capturedAuthUrl).toContain("127.0.0.1");
218
+ expect(capturedAuthUrl).toMatch(/localhost|127\.0\.0\.1/);
218
219
  expect(capturedAuthUrl).toContain(encodeURIComponent("/oauth/callback"));
219
220
 
220
221
  // Extract the redirect_uri and simulate the callback
@@ -277,7 +278,7 @@ describe("OAuth2 gateway transport", () => {
277
278
  await new Promise((r) => setTimeout(r, 50));
278
279
 
279
280
  // Should use loopback redirect even though gateway URL is available
280
- expect(capturedAuthUrl).toContain("127.0.0.1");
281
+ expect(capturedAuthUrl).toMatch(/localhost|127\.0\.0\.1/);
281
282
  expect(capturedAuthUrl).not.toContain("gw.example.com");
282
283
 
283
284
  // Simulate callback to loopback server
@@ -395,7 +396,7 @@ describe("OAuth2 gateway transport", () => {
395
396
  await new Promise((r) => setTimeout(r, 50));
396
397
 
397
398
  expect(capturedAuthUrl).toContain("redirect_uri=");
398
- expect(capturedAuthUrl).toContain("127.0.0.1");
399
+ expect(capturedAuthUrl).toMatch(/localhost|127\.0\.0\.1/);
399
400
  expect(capturedAuthUrl).toContain("code_challenge=");
400
401
  expect(capturedAuthUrl).toContain("code_challenge_method=S256");
401
402
 
@@ -76,7 +76,7 @@ describe("buildStarterTaskPlaybookSection", () => {
76
76
  const section = buildStarterTaskPlaybookSection();
77
77
  expect(section).toContain("### Playbook: make_it_yours");
78
78
  expect(section).toContain("accent color");
79
- expect(section).toContain("Dashboard Color Preference");
79
+ expect(section).toContain("Color Preference");
80
80
  expect(section).toContain("user_selected");
81
81
  });
82
82
 
@@ -73,8 +73,7 @@ describe("onboarding template contracts", () => {
73
73
  // User detail fields must be resolved (provided, inferred, or declined)
74
74
  expect(lower).toContain("resolved");
75
75
  expect(lower).toContain("work role");
76
- expect(lower).toContain("2 suggestions shown");
77
- expect(lower).toContain("selected one, deferred both");
76
+ expect(lower).toContain("2 suggestions from step 6");
78
77
  });
79
78
 
80
79
  test("contains refusal policy", () => {
@@ -23,17 +23,6 @@ describe("resolvePricing", () => {
23
23
  expect(result.estimatedCostUsd).toBe(5 + 25);
24
24
  });
25
25
 
26
- test("returns priced for claude-opus-4-6-fast", () => {
27
- const result = resolvePricing(
28
- "anthropic",
29
- "claude-opus-4-6-fast",
30
- 1_000_000,
31
- 1_000_000,
32
- );
33
- expect(result.pricingStatus).toBe("priced");
34
- expect(result.estimatedCostUsd).toBe(30 + 150);
35
- });
36
-
37
26
  test("returns priced for claude-opus-4", () => {
38
27
  const result = resolvePricing(
39
28
  "anthropic",
@@ -5,13 +5,25 @@ import type { AssistantConfig } from "../config/types.js";
5
5
  import type { Provider, ProviderResponse } from "../providers/types.js";
6
6
  import type { CommitContext } from "../workspace/commit-message-provider.js";
7
7
 
8
+ // ---------------------------------------------------------------------------
9
+ // Mock secure keys — controls what getSecureKeyAsync returns per provider
10
+ // ---------------------------------------------------------------------------
11
+ let mockSecureKeys: Record<string, string> = {};
12
+ mock.module("../security/secure-keys.js", () => ({
13
+ getSecureKey: (name: string) => mockSecureKeys[name] ?? undefined,
14
+ getSecureKeyAsync: async (name: string) => mockSecureKeys[name] ?? undefined,
15
+ setSecureKey: () => true,
16
+ setSecureKeyAsync: async () => true,
17
+ deleteSecureKey: () => "deleted",
18
+ deleteSecureKeyAsync: async () => "deleted" as const,
19
+ }));
20
+
8
21
  // ---------------------------------------------------------------------------
9
22
  // Deep-clone a base config so each test can tweak fields independently
10
23
  // ---------------------------------------------------------------------------
11
24
  function cloneConfig(): AssistantConfig {
12
25
  const cfg = structuredClone(DEFAULT_CONFIG);
13
26
  cfg.provider = "anthropic";
14
- cfg.apiKeys = { anthropic: "sk-test-key" } as Record<string, string>;
15
27
  cfg.workspaceGit.commitMessageLLM = {
16
28
  ...cfg.workspaceGit.commitMessageLLM,
17
29
  enabled: true,
@@ -116,6 +128,7 @@ describe("ProviderCommitMessageGenerator", () => {
116
128
  beforeEach(() => {
117
129
  _resetCommitMessageGenerator();
118
130
  currentConfig = cloneConfig();
131
+ mockSecureKeys = { anthropic: "sk-test-key" };
119
132
  mockSendMessage.mockReset();
120
133
  resolvedProvider = {
121
134
  provider: mockProvider,
@@ -149,7 +162,7 @@ describe("ProviderCommitMessageGenerator", () => {
149
162
 
150
163
  // 3. missing API key
151
164
  test('missing API key → returns deterministic, reason "missing_provider_api_key"', async () => {
152
- currentConfig.apiKeys = {} as Record<string, string>;
165
+ mockSecureKeys = {};
153
166
  const gen = getCommitMessageGenerator();
154
167
  const result = await gen.generateCommitMessage(baseContext, {
155
168
  changedFiles: baseContext.changedFiles,
@@ -161,7 +174,7 @@ describe("ProviderCommitMessageGenerator", () => {
161
174
 
162
175
  // 3b. No resolvable provider and no keys
163
176
  test('no resolvable provider + no keys → returns deterministic, reason "missing_provider_api_key"', async () => {
164
- currentConfig.apiKeys = {} as Record<string, string>;
177
+ mockSecureKeys = {};
165
178
  resolvedProvider = null;
166
179
  const gen = getCommitMessageGenerator();
167
180
  const result = await gen.generateCommitMessage(baseContext, {
@@ -174,10 +187,7 @@ describe("ProviderCommitMessageGenerator", () => {
174
187
 
175
188
  // 3c. No resolvable provider despite keys
176
189
  test('no resolvable provider with keys present → returns deterministic, reason "provider_not_initialized"', async () => {
177
- currentConfig.apiKeys = { anthropic: "sk-test-key" } as Record<
178
- string,
179
- string
180
- >;
190
+ mockSecureKeys = { anthropic: "sk-test-key" };
181
191
  resolvedProvider = null;
182
192
  const gen = getCommitMessageGenerator();
183
193
  const result = await gen.generateCommitMessage(baseContext, {
@@ -332,7 +342,7 @@ describe("ProviderCommitMessageGenerator", () => {
332
342
  // 12. Keyless provider (Ollama) without fast model → missing_fast_model (skips API key check)
333
343
  test('Ollama without API key or fast model → returns deterministic, reason "missing_fast_model"', async () => {
334
344
  (currentConfig as Record<string, unknown>).provider = "ollama";
335
- currentConfig.apiKeys = {} as Record<string, string>;
345
+ mockSecureKeys = {};
336
346
  resolvedProvider = {
337
347
  provider: mockProvider,
338
348
  configuredProviderName: "ollama",
@@ -352,10 +362,7 @@ describe("ProviderCommitMessageGenerator", () => {
352
362
  // 13. Unknown provider without fast model default → missing_fast_model, no provider call
353
363
  test('Unknown provider without fast model default → returns deterministic, reason "missing_fast_model"', async () => {
354
364
  (currentConfig as Record<string, unknown>).provider = "exotic-provider";
355
- currentConfig.apiKeys = { "exotic-provider": "sk-exotic" } as Record<
356
- string,
357
- string
358
- >;
365
+ mockSecureKeys = { "exotic-provider": "sk-exotic" };
359
366
  resolvedProvider = {
360
367
  provider: mockProvider,
361
368
  configuredProviderName: "exotic-provider",
@@ -374,7 +381,7 @@ describe("ProviderCommitMessageGenerator", () => {
374
381
  // 14. Fast-model override enables LLM path for provider without built-in default
375
382
  test("fast-model override enables LLM path for provider without built-in default", async () => {
376
383
  (currentConfig as Record<string, unknown>).provider = "ollama";
377
- currentConfig.apiKeys = {} as Record<string, string>; // Ollama is keyless
384
+ mockSecureKeys = {}; // Ollama is keyless
378
385
  resolvedProvider = {
379
386
  provider: mockProvider,
380
387
  configuredProviderName: "ollama",
@@ -403,7 +410,7 @@ describe("ProviderCommitMessageGenerator", () => {
403
410
  test("configured provider unavailable -> selected fallback provider model mapping is used", async () => {
404
411
  currentConfig.provider = "anthropic";
405
412
  currentConfig.providerOrder = ["openai"];
406
- currentConfig.apiKeys = { openai: "sk-openai" } as Record<string, string>;
413
+ mockSecureKeys = { openai: "sk-openai" };
407
414
  resolvedProvider = {
408
415
  provider: mockProvider,
409
416
  configuredProviderName: "anthropic",
@@ -10,6 +10,7 @@ import { credentialKey } from "../security/credential-key.js";
10
10
  // ---------------------------------------------------------------------------
11
11
  let mockPlatformBaseUrl = "";
12
12
  let mockAssistantApiKey = "";
13
+ let mockProviderKeys: Record<string, string> = {};
13
14
 
14
15
  const actualEnv = await import("../config/env.js");
15
16
  mock.module("../config/env.js", () => ({
@@ -24,7 +25,7 @@ mock.module("../security/secure-keys.js", () => ({
24
25
  if (key === credentialKey("vellum", "assistant_api_key")) {
25
26
  return mockAssistantApiKey || null;
26
27
  }
27
- return null;
28
+ return mockProviderKeys[key] ?? null;
28
29
  },
29
30
  }));
30
31
 
@@ -44,8 +45,8 @@ import { ProviderNotConfiguredError } from "../util/errors.js";
44
45
 
45
46
  /** Initialize registry with anthropic + openai for most tests. */
46
47
  function setupTwoProviders() {
48
+ mockProviderKeys = { anthropic: "test-key", openai: "test-key" };
47
49
  initializeProviders({
48
- apiKeys: { anthropic: "test-key", openai: "test-key" },
49
50
  provider: "anthropic",
50
51
  model: "test-model",
51
52
  });
@@ -53,8 +54,8 @@ function setupTwoProviders() {
53
54
 
54
55
  /** Initialize registry with no providers (empty keys, non-registerable primary). */
55
56
  function setupNoProviders() {
57
+ mockProviderKeys = {};
56
58
  initializeProviders({
57
- apiKeys: {},
58
59
  provider: "gemini",
59
60
  model: "test-model",
60
61
  });
@@ -183,8 +184,8 @@ describe("managed proxy fallback", () => {
183
184
  test("openai registered via managed fallback when no user key but proxy context is valid", () => {
184
185
  enableManagedProxy();
185
186
  try {
187
+ mockProviderKeys = { anthropic: "test-key" };
186
188
  initializeProviders({
187
- apiKeys: { anthropic: "test-key" },
188
189
  provider: "anthropic",
189
190
  model: "test-model",
190
191
  });
@@ -200,8 +201,8 @@ describe("managed proxy fallback", () => {
200
201
  test("user key takes precedence over managed fallback", () => {
201
202
  enableManagedProxy();
202
203
  try {
204
+ mockProviderKeys = { anthropic: "test-key", openai: "user-openai-key" };
203
205
  initializeProviders({
204
- apiKeys: { anthropic: "test-key", openai: "user-openai-key" },
205
206
  provider: "anthropic",
206
207
  model: "test-model",
207
208
  });
@@ -218,8 +219,8 @@ describe("managed proxy fallback", () => {
218
219
 
219
220
  test("managed fallback not activated when proxy context is disabled", () => {
220
221
  disableManagedProxy();
222
+ mockProviderKeys = { anthropic: "test-key" };
221
223
  initializeProviders({
222
- apiKeys: { anthropic: "test-key" },
223
224
  provider: "anthropic",
224
225
  model: "test-model",
225
226
  });
@@ -232,8 +233,8 @@ describe("managed proxy fallback", () => {
232
233
  test("managed providers participate in failover selection", () => {
233
234
  enableManagedProxy();
234
235
  try {
236
+ mockProviderKeys = { anthropic: "test-key" };
235
237
  initializeProviders({
236
- apiKeys: { anthropic: "test-key" },
237
238
  provider: "anthropic",
238
239
  model: "test-model",
239
240
  });
@@ -257,8 +258,8 @@ describe("managed proxy fallback", () => {
257
258
  enableManagedProxy();
258
259
  try {
259
260
  // No anthropic key, no gemini key — only managed providers available
261
+ mockProviderKeys = {};
260
262
  initializeProviders({
261
- apiKeys: {},
262
263
  provider: "openai",
263
264
  model: "test-model",
264
265
  });