@vellumai/assistant 0.4.37 → 0.4.41

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 (169) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/README.md +13 -13
  3. package/bun.lock +80 -24
  4. package/docs/architecture/integrations.md +126 -128
  5. package/docs/runbook-trusted-contacts.md +1 -1
  6. package/docs/trusted-contact-access.md +12 -12
  7. package/package.json +3 -1
  8. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
  9. package/src/__tests__/app-bundler.test.ts +209 -0
  10. package/src/__tests__/app-compiler.test.ts +279 -0
  11. package/src/__tests__/app-executors.test.ts +293 -483
  12. package/src/__tests__/app-migration.test.ts +148 -0
  13. package/src/__tests__/app-routes-csp.test.ts +202 -0
  14. package/src/__tests__/avatar-e2e.test.ts +452 -0
  15. package/src/__tests__/avatar-generator.test.ts +193 -0
  16. package/src/__tests__/avatar-router.test.ts +186 -0
  17. package/src/__tests__/browser-download-timeout.test.ts +28 -0
  18. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
  19. package/src/__tests__/call-domain.test.ts +3 -7
  20. package/src/__tests__/credential-security-e2e.test.ts +19 -12
  21. package/src/__tests__/credentials-cli.test.ts +30 -4
  22. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
  23. package/src/__tests__/handlers-slack-config.test.ts +0 -72
  24. package/src/__tests__/handlers-telegram-config.test.ts +19 -12
  25. package/src/__tests__/handlers-twitter-config.test.ts +105 -48
  26. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  27. package/src/__tests__/integration-status.test.ts +15 -5
  28. package/src/__tests__/integrations-cli.test.ts +1 -1
  29. package/src/__tests__/invite-redemption-service.test.ts +62 -7
  30. package/src/__tests__/ipc-snapshot.test.ts +0 -8
  31. package/src/__tests__/managed-avatar-client.test.ts +280 -0
  32. package/src/__tests__/mcp-cli.test.ts +3 -3
  33. package/src/__tests__/oauth-cli.test.ts +203 -0
  34. package/src/__tests__/relay-server.test.ts +3 -3
  35. package/src/__tests__/secret-onetime-send.test.ts +19 -12
  36. package/src/__tests__/secure-keys.test.ts +78 -0
  37. package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
  38. package/src/__tests__/slack-channel-config.test.ts +23 -16
  39. package/src/__tests__/slack-share-routes.test.ts +263 -0
  40. package/src/__tests__/sms-messaging-provider.test.ts +3 -1
  41. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
  42. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  43. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  44. package/src/__tests__/twilio-config.test.ts +15 -36
  45. package/src/__tests__/twilio-provider.test.ts +4 -0
  46. package/src/__tests__/twitter-auth-handler.test.ts +27 -14
  47. package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
  48. package/src/__tests__/twitter-cli-routing.test.ts +38 -53
  49. package/src/__tests__/twitter-oauth-client.test.ts +18 -47
  50. package/src/__tests__/voice-invite-redemption.test.ts +27 -3
  51. package/src/amazon/cart.ts +1 -1
  52. package/src/amazon/client.ts +89 -7
  53. package/src/approvals/guardian-request-resolvers.ts +2 -2
  54. package/src/bundler/app-bundler.ts +77 -32
  55. package/src/bundler/app-compiler.ts +195 -0
  56. package/src/bundler/manifest.ts +1 -1
  57. package/src/bundler/package-resolver.ts +185 -0
  58. package/src/calls/call-domain.ts +4 -14
  59. package/src/calls/relay-server.ts +2 -2
  60. package/src/calls/twilio-config.ts +5 -24
  61. package/src/calls/twilio-rest.ts +19 -5
  62. package/src/cli/amazon.ts +74 -249
  63. package/src/cli/audit.ts +2 -2
  64. package/src/cli/autonomy.ts +9 -9
  65. package/src/cli/channels.ts +5 -5
  66. package/src/cli/completions.ts +27 -27
  67. package/src/cli/config.ts +14 -14
  68. package/src/cli/contacts.ts +27 -27
  69. package/src/cli/credentials.ts +28 -28
  70. package/src/cli/dev.ts +2 -2
  71. package/src/cli/doctor.ts +2 -2
  72. package/src/cli/email.ts +82 -82
  73. package/src/cli/influencer.ts +13 -13
  74. package/src/cli/integrations.ts +19 -144
  75. package/src/cli/keys.ts +10 -10
  76. package/src/cli/map.ts +4 -4
  77. package/src/cli/mcp.ts +17 -17
  78. package/src/cli/memory.ts +18 -18
  79. package/src/cli/notifications.ts +13 -13
  80. package/src/cli/oauth.ts +77 -0
  81. package/src/cli/program.ts +2 -0
  82. package/src/cli/sequence.ts +27 -27
  83. package/src/cli/sessions.ts +12 -12
  84. package/src/cli/trust.ts +8 -8
  85. package/src/cli/twitter.ts +124 -70
  86. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  87. package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
  88. package/src/config/bundled-skills/amazon/SKILL.md +54 -54
  89. package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
  90. package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
  91. package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
  92. package/src/config/bundled-skills/contacts/SKILL.md +12 -12
  93. package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
  94. package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
  95. package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
  96. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
  97. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
  98. package/src/config/bundled-skills/influencer/SKILL.md +13 -13
  99. package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
  101. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  102. package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
  103. package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
  104. package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
  105. package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
  106. package/src/config/bundled-skills/twitter/SKILL.md +68 -44
  107. package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
  108. package/src/config/core-schema.ts +26 -0
  109. package/src/config/env.ts +4 -0
  110. package/src/config/feature-flag-registry.json +9 -1
  111. package/src/config/schema.ts +8 -0
  112. package/src/config/system-prompt.ts +6 -3
  113. package/src/config/templates/BOOTSTRAP.md +7 -5
  114. package/src/contacts/contacts-write.ts +5 -1
  115. package/src/daemon/handlers/apps.ts +31 -4
  116. package/src/daemon/handlers/config-ingress.ts +3 -3
  117. package/src/daemon/handlers/config-integrations.ts +120 -49
  118. package/src/daemon/handlers/config-slack-channel.ts +26 -7
  119. package/src/daemon/handlers/config-slack.ts +1 -54
  120. package/src/daemon/handlers/config-telegram.ts +28 -10
  121. package/src/daemon/handlers/config.ts +1 -4
  122. package/src/daemon/handlers/twitter-auth.ts +11 -4
  123. package/src/daemon/ipc-contract/apps.ts +0 -13
  124. package/src/daemon/ipc-contract-inventory.json +0 -2
  125. package/src/daemon/lifecycle.ts +8 -1
  126. package/src/daemon/session-messaging.ts +2 -2
  127. package/src/daemon/tool-side-effects.ts +30 -0
  128. package/src/email/providers/agentmail.ts +1 -1
  129. package/src/email/providers/index.ts +1 -1
  130. package/src/email/service.ts +1 -1
  131. package/src/gallery/default-gallery.ts +538 -0
  132. package/src/gallery/gallery-manifest.ts +5 -1
  133. package/src/influencer/client.ts +8 -6
  134. package/src/mcp/client.ts +1 -1
  135. package/src/media/avatar-router.ts +99 -0
  136. package/src/media/avatar-types.ts +60 -0
  137. package/src/media/managed-avatar-client.ts +189 -0
  138. package/src/memory/app-migration.ts +114 -0
  139. package/src/memory/app-store.ts +11 -0
  140. package/src/memory/qdrant-client.ts +1 -1
  141. package/src/messaging/providers/slack/client.ts +12 -2
  142. package/src/messaging/providers/sms/adapter.ts +6 -10
  143. package/src/migrations/data-layout.ts +8 -1
  144. package/src/oauth/token-persistence.ts +9 -6
  145. package/src/runtime/assistant-scope.ts +5 -0
  146. package/src/runtime/auth/route-policy.ts +4 -0
  147. package/src/runtime/channel-readiness-service.ts +9 -4
  148. package/src/runtime/gateway-internal-client.ts +11 -3
  149. package/src/runtime/http-server.ts +2 -0
  150. package/src/runtime/invite-redemption-service.ts +23 -13
  151. package/src/runtime/middleware/twilio-validation.ts +2 -2
  152. package/src/runtime/routes/app-routes.ts +131 -3
  153. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
  154. package/src/runtime/routes/integration-routes.ts +2 -2
  155. package/src/runtime/routes/slack-share-routes.ts +235 -0
  156. package/src/runtime/routes/twilio-routes.ts +47 -34
  157. package/src/schedule/integration-status.ts +2 -3
  158. package/src/security/token-manager.ts +11 -3
  159. package/src/tools/apps/executors.ts +116 -8
  160. package/src/tools/browser/browser-manager.ts +30 -2
  161. package/src/tools/browser/chrome-cdp.ts +31 -3
  162. package/src/tools/credentials/vault.ts +9 -7
  163. package/src/tools/executor.ts +4 -0
  164. package/src/tools/system/avatar-generator.ts +55 -34
  165. package/src/twitter/client.ts +1 -1
  166. package/src/twitter/oauth-client.ts +31 -43
  167. package/src/twitter/router.ts +25 -23
  168. package/src/util/platform.ts +5 -0
  169. package/src/slack/slack-webhook.ts +0 -66
@@ -0,0 +1,452 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock state — mutable variables control per-test behavior
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockStrategy: string = "local_only";
8
+ let mockGeminiKey: string | undefined = "test-gemini-key";
9
+ let mockApiKey: string | undefined = "test-api-key-123";
10
+ let mockBaseUrl = "https://platform.test.vellum.ai";
11
+ let mockPlatformEnvUrl = "https://env.test.vellum.ai";
12
+ let mockWorkspaceDir = "/tmp/test-workspace-e2e";
13
+
14
+ const mkdirSyncFn = mock(() => {});
15
+ const writeFileSyncFn = mock(() => {});
16
+ const renameSyncFn = mock(() => {});
17
+
18
+ let logInfoCalls: Array<[unknown, string]> = [];
19
+ let logWarnCalls: Array<[unknown, string]> = [];
20
+ let logErrorCalls: Array<[unknown, string]> = [];
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Gemini mock state
24
+ // ---------------------------------------------------------------------------
25
+
26
+ let geminiGenerateContentResult: unknown;
27
+ const geminiGenerateContentFn = mock(async () => geminiGenerateContentResult);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Mock modules — must be before importing the module under test
31
+ // ---------------------------------------------------------------------------
32
+
33
+ mock.module("../config/loader.js", () => ({
34
+ getConfig: () => ({
35
+ apiKeys: { gemini: mockGeminiKey },
36
+ avatar: { generationStrategy: mockStrategy },
37
+ platform: { baseUrl: mockBaseUrl },
38
+ imageGenModel: "gemini-2.5-flash-image",
39
+ }),
40
+ }));
41
+
42
+ mock.module("../config/env.js", () => ({
43
+ getPlatformBaseUrl: () => mockPlatformEnvUrl,
44
+ }));
45
+
46
+ mock.module("../security/secure-keys.js", () => ({
47
+ getSecureKey: (account: string) => {
48
+ if (account === "credential:vellum:assistant_api_key") return mockApiKey;
49
+ return undefined;
50
+ },
51
+ }));
52
+
53
+ mock.module("../util/platform.js", () => ({
54
+ getWorkspaceDir: () => mockWorkspaceDir,
55
+ }));
56
+
57
+ mock.module("../util/logger.js", () => ({
58
+ getLogger: () => ({
59
+ debug: () => {},
60
+ info: (...args: unknown[]) => {
61
+ logInfoCalls.push(args as [unknown, string]);
62
+ },
63
+ warn: (...args: unknown[]) => {
64
+ logWarnCalls.push(args as [unknown, string]);
65
+ },
66
+ error: (...args: unknown[]) => {
67
+ logErrorCalls.push(args as [unknown, string]);
68
+ },
69
+ }),
70
+ }));
71
+
72
+ mock.module("node:fs", () => ({
73
+ mkdirSync: mkdirSyncFn,
74
+ writeFileSync: writeFileSyncFn,
75
+ renameSync: renameSyncFn,
76
+ }));
77
+
78
+ mock.module("node:crypto", () => ({
79
+ randomUUID: () => "00000000-0000-0000-0000-000000000000",
80
+ }));
81
+
82
+ mock.module("@google/genai", () => ({
83
+ GoogleGenAI: class {
84
+ models = {
85
+ generateContent: geminiGenerateContentFn,
86
+ };
87
+ },
88
+ ApiError: class extends Error {
89
+ status: number;
90
+ constructor(message: string, status: number) {
91
+ super(message);
92
+ this.status = status;
93
+ }
94
+ },
95
+ }));
96
+
97
+ // Import after all mocks are set up
98
+ import { AVATAR_MAX_DECODED_BYTES } from "../media/avatar-types.js";
99
+ import { setAvatarTool } from "../tools/system/avatar-generator.js";
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function executeAvatar(description: string) {
106
+ return setAvatarTool.execute(
107
+ { description },
108
+ {} as Parameters<typeof setAvatarTool.execute>[1],
109
+ );
110
+ }
111
+
112
+ /** Standard successful managed avatar platform response. */
113
+ function managedPlatformResponse() {
114
+ return {
115
+ image: {
116
+ mime_type: "image/png",
117
+ data_base64: "iVBORw0KGgoAAAANSUhEUg==",
118
+ bytes: 1024,
119
+ sha256: "abc123",
120
+ },
121
+ usage: { billable: true, class_name: "assistant_avatar_system" },
122
+ generation_source: "vertex",
123
+ profile: "avatar_v1",
124
+ correlation_id: "managed-corr-id-123",
125
+ };
126
+ }
127
+
128
+ /** Standard successful Gemini generateContent response. */
129
+ function geminiContentResponse() {
130
+ return {
131
+ candidates: [
132
+ {
133
+ content: {
134
+ parts: [
135
+ {
136
+ inlineData: {
137
+ mimeType: "image/png",
138
+ data: "iVBORw0KGgoAAAANSUhEUg==",
139
+ },
140
+ },
141
+ ],
142
+ },
143
+ },
144
+ ],
145
+ };
146
+ }
147
+
148
+ function mockFetchReturning(response: {
149
+ ok: boolean;
150
+ status: number;
151
+ body: unknown;
152
+ }) {
153
+ globalThis.fetch = mock(() =>
154
+ Promise.resolve({
155
+ ok: response.ok,
156
+ status: response.status,
157
+ json: async () => response.body,
158
+ }),
159
+ ) as unknown as typeof globalThis.fetch;
160
+ }
161
+
162
+ const originalFetch = globalThis.fetch;
163
+
164
+ const expectedAvatarPath =
165
+ "/tmp/test-workspace-e2e/data/avatar/custom-avatar.png";
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Tests
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe("avatar E2E integration", () => {
172
+ // Save original GEMINI_API_KEY to restore after each test
173
+ const originalGeminiKey = process.env.GEMINI_API_KEY;
174
+
175
+ beforeEach(() => {
176
+ mockStrategy = "local_only";
177
+ mockGeminiKey = "test-gemini-key";
178
+ mockApiKey = "test-api-key-123";
179
+ mockBaseUrl = "https://platform.test.vellum.ai";
180
+ mockPlatformEnvUrl = "https://env.test.vellum.ai";
181
+ mockWorkspaceDir = "/tmp/test-workspace-e2e";
182
+
183
+ mkdirSyncFn.mockClear();
184
+ writeFileSyncFn.mockClear();
185
+ renameSyncFn.mockClear();
186
+ geminiGenerateContentFn.mockClear();
187
+
188
+ logInfoCalls = [];
189
+ logWarnCalls = [];
190
+ logErrorCalls = [];
191
+
192
+ geminiGenerateContentResult = geminiContentResponse();
193
+ globalThis.fetch = originalFetch;
194
+
195
+ // Clear env var so tests control the key entirely via config mock
196
+ delete process.env.GEMINI_API_KEY;
197
+ });
198
+
199
+ afterEach(() => {
200
+ // Restore original GEMINI_API_KEY
201
+ if (originalGeminiKey === undefined) {
202
+ delete process.env.GEMINI_API_KEY;
203
+ } else {
204
+ process.env.GEMINI_API_KEY = originalGeminiKey;
205
+ }
206
+ });
207
+
208
+ // -----------------------------------------------------------------------
209
+ // 1. Managed success E2E
210
+ // -----------------------------------------------------------------------
211
+
212
+ test("managed_required success — file written, correct content, success message", async () => {
213
+ mockStrategy = "managed_required";
214
+ mockFetchReturning({
215
+ ok: true,
216
+ status: 200,
217
+ body: managedPlatformResponse(),
218
+ });
219
+
220
+ const result = await executeAvatar("a friendly robot");
221
+
222
+ // Verify success message
223
+ expect(result.isError).toBe(false);
224
+ expect(result.content).toContain("Avatar updated");
225
+
226
+ // Verify file was written
227
+ expect(mkdirSyncFn).toHaveBeenCalledTimes(1);
228
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
229
+ expect(renameSyncFn).toHaveBeenCalledTimes(1);
230
+
231
+ // Verify rename target is the expected avatar path
232
+ expect((renameSyncFn.mock.calls[0] as unknown[])[1]).toBe(
233
+ expectedAvatarPath,
234
+ );
235
+
236
+ // Verify the written buffer content matches the base64 data
237
+ const writtenBuffer = (
238
+ writeFileSyncFn.mock.calls[0] as unknown[]
239
+ )[1] as Buffer;
240
+ const expectedBuffer = Buffer.from("iVBORw0KGgoAAAANSUhEUg==", "base64");
241
+ expect(writtenBuffer.equals(expectedBuffer)).toBe(true);
242
+
243
+ // Verify Gemini was never called
244
+ expect(geminiGenerateContentFn).not.toHaveBeenCalled();
245
+ });
246
+
247
+ // -----------------------------------------------------------------------
248
+ // 2. Managed-required failure E2E — no fallback
249
+ // -----------------------------------------------------------------------
250
+
251
+ test("managed_required failure — error surfaced, no file written, no fallback", async () => {
252
+ mockStrategy = "managed_required";
253
+ mockFetchReturning({
254
+ ok: false,
255
+ status: 500,
256
+ body: {
257
+ code: "internal_error",
258
+ subcode: "server_fault",
259
+ detail: "Internal server error",
260
+ retryable: true,
261
+ correlation_id: "corr-500",
262
+ },
263
+ });
264
+
265
+ const result = await executeAvatar("a friendly robot");
266
+
267
+ // Verify error is surfaced
268
+ expect(result.isError).toBe(true);
269
+ expect(result.content).toContain("failed");
270
+
271
+ // Verify no file was written
272
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
273
+ expect(renameSyncFn).not.toHaveBeenCalled();
274
+
275
+ // Verify Gemini was never called (no fallback)
276
+ expect(geminiGenerateContentFn).not.toHaveBeenCalled();
277
+ });
278
+
279
+ // -----------------------------------------------------------------------
280
+ // 3. Managed-prefer fallback E2E
281
+ // -----------------------------------------------------------------------
282
+
283
+ test("managed_prefer fallback — managed fails, local Gemini used, file written", async () => {
284
+ mockStrategy = "managed_prefer";
285
+ mockFetchReturning({
286
+ ok: false,
287
+ status: 502,
288
+ body: {
289
+ code: "upstream_error",
290
+ subcode: "bad_gateway",
291
+ detail: "Bad gateway",
292
+ retryable: true,
293
+ correlation_id: "corr-502",
294
+ },
295
+ });
296
+
297
+ const result = await executeAvatar("a cute cat");
298
+
299
+ // Verify success — local path was used
300
+ expect(result.isError).toBe(false);
301
+ expect(result.content).toContain("Avatar updated");
302
+
303
+ // Verify file was written
304
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
305
+ expect(renameSyncFn).toHaveBeenCalledTimes(1);
306
+
307
+ // Verify Gemini was called as fallback
308
+ expect(geminiGenerateContentFn).toHaveBeenCalledTimes(1);
309
+ });
310
+
311
+ // -----------------------------------------------------------------------
312
+ // 4. Managed-prefer no-fallback when managed unavailable (no API key)
313
+ // -----------------------------------------------------------------------
314
+
315
+ test("managed_prefer without API key — goes straight to local Gemini", async () => {
316
+ mockStrategy = "managed_prefer";
317
+ mockApiKey = undefined; // No managed API key available
318
+
319
+ const result = await executeAvatar("a cute cat");
320
+
321
+ // Verify success via local path
322
+ expect(result.isError).toBe(false);
323
+ expect(result.content).toContain("Avatar updated");
324
+
325
+ // Verify Gemini was called directly (no managed attempt)
326
+ expect(geminiGenerateContentFn).toHaveBeenCalledTimes(1);
327
+
328
+ // Verify file was written
329
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
330
+ expect(renameSyncFn).toHaveBeenCalledTimes(1);
331
+ });
332
+
333
+ // -----------------------------------------------------------------------
334
+ // 5. Local-only E2E
335
+ // -----------------------------------------------------------------------
336
+
337
+ test("local_only — only local Gemini called, managed never contacted", async () => {
338
+ mockStrategy = "local_only";
339
+ const fetchMock = mock(() =>
340
+ Promise.resolve({ ok: true, status: 200, json: async () => ({}) }),
341
+ );
342
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
343
+
344
+ const result = await executeAvatar("a whimsical owl");
345
+
346
+ // Verify success
347
+ expect(result.isError).toBe(false);
348
+ expect(result.content).toContain("Avatar updated");
349
+
350
+ // Verify Gemini was called
351
+ expect(geminiGenerateContentFn).toHaveBeenCalledTimes(1);
352
+
353
+ // Verify managed endpoint was never contacted
354
+ expect(fetchMock).not.toHaveBeenCalled();
355
+
356
+ // Verify file was written
357
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
358
+ expect(renameSyncFn).toHaveBeenCalledTimes(1);
359
+ });
360
+
361
+ // -----------------------------------------------------------------------
362
+ // 6. Response validation — bad MIME type
363
+ // -----------------------------------------------------------------------
364
+
365
+ test("managed response with disallowed MIME type — rejected, no file written", async () => {
366
+ mockStrategy = "managed_required";
367
+ const badResponse = managedPlatformResponse();
368
+ badResponse.image.mime_type = "image/gif";
369
+ mockFetchReturning({ ok: true, status: 200, body: badResponse });
370
+
371
+ const result = await executeAvatar("a cat");
372
+
373
+ // Verify error returned
374
+ expect(result.isError).toBe(true);
375
+ expect(result.content).toContain("failed");
376
+
377
+ // Verify no file was written
378
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
379
+ expect(renameSyncFn).not.toHaveBeenCalled();
380
+ });
381
+
382
+ // -----------------------------------------------------------------------
383
+ // 7. Response validation — oversized image
384
+ // -----------------------------------------------------------------------
385
+
386
+ test("managed response with oversized data — rejected, no file written", async () => {
387
+ mockStrategy = "managed_required";
388
+ const oversizedResponse = managedPlatformResponse();
389
+ oversizedResponse.image.bytes = AVATAR_MAX_DECODED_BYTES + 1;
390
+ mockFetchReturning({ ok: true, status: 200, body: oversizedResponse });
391
+
392
+ const result = await executeAvatar("a cat");
393
+
394
+ // Verify error returned
395
+ expect(result.isError).toBe(true);
396
+ expect(result.content).toContain("failed");
397
+
398
+ // Verify no file was written
399
+ expect(writeFileSyncFn).not.toHaveBeenCalled();
400
+ expect(renameSyncFn).not.toHaveBeenCalled();
401
+ });
402
+
403
+ // -----------------------------------------------------------------------
404
+ // 8. Rate limit user message
405
+ // -----------------------------------------------------------------------
406
+
407
+ test("managed 429 response — user-friendly rate limit message", async () => {
408
+ mockStrategy = "managed_required";
409
+ mockFetchReturning({
410
+ ok: false,
411
+ status: 429,
412
+ body: {
413
+ code: "avatar_rate_limited",
414
+ subcode: "too_many_requests",
415
+ detail: "Rate limit exceeded",
416
+ retryable: true,
417
+ correlation_id: "corr-429",
418
+ },
419
+ });
420
+
421
+ const result = await executeAvatar("a cat");
422
+
423
+ expect(result.isError).toBe(true);
424
+ expect(result.content.toLowerCase()).toContain("rate limited");
425
+ });
426
+
427
+ // -----------------------------------------------------------------------
428
+ // 9. Correlation ID propagation
429
+ // -----------------------------------------------------------------------
430
+
431
+ test("managed success — correlation ID from response is logged", async () => {
432
+ mockStrategy = "managed_required";
433
+ const response = managedPlatformResponse();
434
+ response.correlation_id = "unique-corr-id-xyz";
435
+ mockFetchReturning({ ok: true, status: 200, body: response });
436
+
437
+ await executeAvatar("a robot");
438
+
439
+ // Look for the correlation ID in info log calls
440
+ const correlationLogged = logInfoCalls.some(([meta]) => {
441
+ if (meta && typeof meta === "object" && "correlationId" in meta) {
442
+ return (
443
+ (meta as { correlationId: string }).correlationId ===
444
+ "unique-corr-id-xyz"
445
+ );
446
+ }
447
+ return false;
448
+ });
449
+
450
+ expect(correlationLogged).toBe(true);
451
+ });
452
+ });
@@ -0,0 +1,193 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { ManagedAvatarError } from "../media/avatar-types.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock state
7
+ // ---------------------------------------------------------------------------
8
+
9
+ let mockRouterResult: unknown;
10
+ let mockRouterError: Error | undefined;
11
+ let mockWorkspaceDir = "/tmp/test-workspace";
12
+
13
+ const routedGenerateAvatarFn = mock(async () => {
14
+ if (mockRouterError) throw mockRouterError;
15
+ return mockRouterResult;
16
+ });
17
+
18
+ const mkdirSyncFn = mock(() => {});
19
+ const writeFileSyncFn = mock(() => {});
20
+ const renameSyncFn = mock(() => {});
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mock modules — before importing module under test
24
+ // ---------------------------------------------------------------------------
25
+
26
+ mock.module("../media/avatar-router.js", () => ({
27
+ routedGenerateAvatar: routedGenerateAvatarFn,
28
+ }));
29
+
30
+ mock.module("../util/logger.js", () => ({
31
+ getLogger: () => ({
32
+ debug: () => {},
33
+ info: () => {},
34
+ warn: () => {},
35
+ error: () => {},
36
+ }),
37
+ }));
38
+
39
+ mock.module("../util/platform.js", () => ({
40
+ getWorkspaceDir: () => mockWorkspaceDir,
41
+ }));
42
+
43
+ mock.module("node:fs", () => ({
44
+ mkdirSync: mkdirSyncFn,
45
+ writeFileSync: writeFileSyncFn,
46
+ renameSync: renameSyncFn,
47
+ }));
48
+
49
+ // Import after mocking
50
+ import { setAvatarTool } from "../tools/system/avatar-generator.js";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function successResult() {
57
+ return {
58
+ imageBase64: "iVBORw0KGgoAAAANSUhEUg==",
59
+ mimeType: "image/png",
60
+ pathUsed: "local" as const,
61
+ correlationId: "test-corr-id",
62
+ };
63
+ }
64
+
65
+ function executeAvatar(description: string) {
66
+ return setAvatarTool.execute(
67
+ { description },
68
+ {} as Parameters<typeof setAvatarTool.execute>[1],
69
+ );
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Tests
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("setAvatarTool", () => {
77
+ beforeEach(() => {
78
+ mockRouterResult = successResult();
79
+ mockRouterError = undefined;
80
+ mockWorkspaceDir = "/tmp/test-workspace";
81
+ routedGenerateAvatarFn.mockClear();
82
+ mkdirSyncFn.mockClear();
83
+ writeFileSyncFn.mockClear();
84
+ renameSyncFn.mockClear();
85
+ });
86
+
87
+ test("successful generation writes PNG and returns success message", async () => {
88
+ const result = await executeAvatar("a friendly purple cat");
89
+
90
+ expect(result.isError).toBe(false);
91
+ expect(result.content).toContain("Avatar updated");
92
+ expect(routedGenerateAvatarFn).toHaveBeenCalledTimes(1);
93
+ });
94
+
95
+ test("empty description returns error", async () => {
96
+ const result = await executeAvatar("");
97
+
98
+ expect(result.isError).toBe(true);
99
+ expect(result.content).toContain("description is required");
100
+ expect(routedGenerateAvatarFn).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test("no image data returned yields error", async () => {
104
+ mockRouterResult = { ...successResult(), imageBase64: "" };
105
+
106
+ const result = await executeAvatar("a cat");
107
+
108
+ expect(result.isError).toBe(true);
109
+ expect(result.content).toContain("No image data returned");
110
+ });
111
+
112
+ test("ManagedAvatarError with statusCode 429 returns user-friendly rate limit message", async () => {
113
+ mockRouterError = new ManagedAvatarError({
114
+ code: "some_error_code",
115
+ subcode: "too_many_requests",
116
+ detail: "Rate limited",
117
+ retryable: true,
118
+ correlationId: "corr-rate",
119
+ statusCode: 429,
120
+ });
121
+
122
+ const result = await executeAvatar("a cat");
123
+
124
+ expect(result.isError).toBe(true);
125
+ expect(result.content).toContain("rate limited");
126
+ });
127
+
128
+ test("ManagedAvatarError with 503 returns service unavailable message", async () => {
129
+ mockRouterError = new ManagedAvatarError({
130
+ code: "avatar_service_error",
131
+ subcode: "upstream_unavailable",
132
+ detail: "Service down",
133
+ retryable: true,
134
+ correlationId: "corr-503",
135
+ statusCode: 503,
136
+ });
137
+
138
+ const result = await executeAvatar("a cat");
139
+
140
+ expect(result.isError).toBe(true);
141
+ expect(result.content).toContain("temporarily unavailable");
142
+ });
143
+
144
+ test("ManagedAvatarError with other code returns detail message", async () => {
145
+ mockRouterError = new ManagedAvatarError({
146
+ code: "avatar_content_filtered",
147
+ subcode: "policy_violation",
148
+ detail: "Content was filtered by safety policy",
149
+ retryable: false,
150
+ correlationId: "corr-filter",
151
+ statusCode: 400,
152
+ });
153
+
154
+ const result = await executeAvatar("a cat");
155
+
156
+ expect(result.isError).toBe(true);
157
+ expect(result.content).toContain("Content was filtered by safety policy");
158
+ });
159
+
160
+ test("generic error returns mapped message", async () => {
161
+ mockRouterError = new Error("Network timeout");
162
+
163
+ const result = await executeAvatar("a cat");
164
+
165
+ expect(result.isError).toBe(true);
166
+ expect(result.content).toContain(
167
+ "Image generation failed: Network timeout",
168
+ );
169
+ });
170
+
171
+ test("atomic write — file is written to .tmp then renamed", async () => {
172
+ await executeAvatar("a friendly cat");
173
+
174
+ const expectedPath = "/tmp/test-workspace/data/avatar/custom-avatar.png";
175
+
176
+ // Verify mkdirSync was called for the directory
177
+ expect(mkdirSyncFn).toHaveBeenCalledTimes(1);
178
+ expect((mkdirSyncFn.mock.calls[0] as unknown[])[1]).toEqual({
179
+ recursive: true,
180
+ });
181
+
182
+ // Verify writeFileSync writes to a unique tmp path
183
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(1);
184
+ const tmpPath = (writeFileSyncFn.mock.calls[0] as unknown[])[0] as string;
185
+ expect(tmpPath).toStartWith(expectedPath + ".");
186
+ expect(tmpPath).toEndWith(".tmp");
187
+
188
+ // Verify renameSync moves tmp to final path
189
+ expect(renameSyncFn).toHaveBeenCalledTimes(1);
190
+ expect((renameSyncFn.mock.calls[0] as unknown[])[0]).toBe(tmpPath);
191
+ expect((renameSyncFn.mock.calls[0] as unknown[])[1]).toBe(expectedPath);
192
+ });
193
+ });