@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.
- package/ARCHITECTURE.md +3 -3
- package/README.md +13 -13
- package/bun.lock +80 -24
- package/docs/architecture/integrations.md +126 -128
- package/docs/runbook-trusted-contacts.md +1 -1
- package/docs/trusted-contact-access.md +12 -12
- package/package.json +3 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
- package/src/__tests__/app-bundler.test.ts +209 -0
- package/src/__tests__/app-compiler.test.ts +279 -0
- package/src/__tests__/app-executors.test.ts +293 -483
- package/src/__tests__/app-migration.test.ts +148 -0
- package/src/__tests__/app-routes-csp.test.ts +202 -0
- package/src/__tests__/avatar-e2e.test.ts +452 -0
- package/src/__tests__/avatar-generator.test.ts +193 -0
- package/src/__tests__/avatar-router.test.ts +186 -0
- package/src/__tests__/browser-download-timeout.test.ts +28 -0
- package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
- package/src/__tests__/call-domain.test.ts +3 -7
- package/src/__tests__/credential-security-e2e.test.ts +19 -12
- package/src/__tests__/credentials-cli.test.ts +30 -4
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
- package/src/__tests__/handlers-slack-config.test.ts +0 -72
- package/src/__tests__/handlers-telegram-config.test.ts +19 -12
- package/src/__tests__/handlers-twitter-config.test.ts +105 -48
- package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
- package/src/__tests__/integration-status.test.ts +15 -5
- package/src/__tests__/integrations-cli.test.ts +1 -1
- package/src/__tests__/invite-redemption-service.test.ts +62 -7
- package/src/__tests__/ipc-snapshot.test.ts +0 -8
- package/src/__tests__/managed-avatar-client.test.ts +280 -0
- package/src/__tests__/mcp-cli.test.ts +3 -3
- package/src/__tests__/oauth-cli.test.ts +203 -0
- package/src/__tests__/relay-server.test.ts +3 -3
- package/src/__tests__/secret-onetime-send.test.ts +19 -12
- package/src/__tests__/secure-keys.test.ts +78 -0
- package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
- package/src/__tests__/slack-channel-config.test.ts +23 -16
- package/src/__tests__/slack-share-routes.test.ts +263 -0
- package/src/__tests__/sms-messaging-provider.test.ts +3 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
- package/src/__tests__/trusted-contact-verification.test.ts +10 -10
- package/src/__tests__/twilio-config.test.ts +15 -36
- package/src/__tests__/twilio-provider.test.ts +4 -0
- package/src/__tests__/twitter-auth-handler.test.ts +27 -14
- package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
- package/src/__tests__/twitter-cli-routing.test.ts +38 -53
- package/src/__tests__/twitter-oauth-client.test.ts +18 -47
- package/src/__tests__/voice-invite-redemption.test.ts +27 -3
- package/src/amazon/cart.ts +1 -1
- package/src/amazon/client.ts +89 -7
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/bundler/app-bundler.ts +77 -32
- package/src/bundler/app-compiler.ts +195 -0
- package/src/bundler/manifest.ts +1 -1
- package/src/bundler/package-resolver.ts +185 -0
- package/src/calls/call-domain.ts +4 -14
- package/src/calls/relay-server.ts +2 -2
- package/src/calls/twilio-config.ts +5 -24
- package/src/calls/twilio-rest.ts +19 -5
- package/src/cli/amazon.ts +74 -249
- package/src/cli/audit.ts +2 -2
- package/src/cli/autonomy.ts +9 -9
- package/src/cli/channels.ts +5 -5
- package/src/cli/completions.ts +27 -27
- package/src/cli/config.ts +14 -14
- package/src/cli/contacts.ts +27 -27
- package/src/cli/credentials.ts +28 -28
- package/src/cli/dev.ts +2 -2
- package/src/cli/doctor.ts +2 -2
- package/src/cli/email.ts +82 -82
- package/src/cli/influencer.ts +13 -13
- package/src/cli/integrations.ts +19 -144
- package/src/cli/keys.ts +10 -10
- package/src/cli/map.ts +4 -4
- package/src/cli/mcp.ts +17 -17
- package/src/cli/memory.ts +18 -18
- package/src/cli/notifications.ts +13 -13
- package/src/cli/oauth.ts +77 -0
- package/src/cli/program.ts +2 -0
- package/src/cli/sequence.ts +27 -27
- package/src/cli/sessions.ts +12 -12
- package/src/cli/trust.ts +8 -8
- package/src/cli/twitter.ts +124 -70
- package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
- package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
- package/src/config/bundled-skills/amazon/SKILL.md +54 -54
- package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
- package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
- package/src/config/bundled-skills/contacts/SKILL.md +12 -12
- package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
- package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
- package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
- package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
- package/src/config/bundled-skills/influencer/SKILL.md +13 -13
- package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
- package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
- package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
- package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
- package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
- package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
- package/src/config/bundled-skills/twitter/SKILL.md +68 -44
- package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
- package/src/config/core-schema.ts +26 -0
- package/src/config/env.ts +4 -0
- package/src/config/feature-flag-registry.json +9 -1
- package/src/config/schema.ts +8 -0
- package/src/config/system-prompt.ts +6 -3
- package/src/config/templates/BOOTSTRAP.md +7 -5
- package/src/contacts/contacts-write.ts +5 -1
- package/src/daemon/handlers/apps.ts +31 -4
- package/src/daemon/handlers/config-ingress.ts +3 -3
- package/src/daemon/handlers/config-integrations.ts +120 -49
- package/src/daemon/handlers/config-slack-channel.ts +26 -7
- package/src/daemon/handlers/config-slack.ts +1 -54
- package/src/daemon/handlers/config-telegram.ts +28 -10
- package/src/daemon/handlers/config.ts +1 -4
- package/src/daemon/handlers/twitter-auth.ts +11 -4
- package/src/daemon/ipc-contract/apps.ts +0 -13
- package/src/daemon/ipc-contract-inventory.json +0 -2
- package/src/daemon/lifecycle.ts +8 -1
- package/src/daemon/session-messaging.ts +2 -2
- package/src/daemon/tool-side-effects.ts +30 -0
- package/src/email/providers/agentmail.ts +1 -1
- package/src/email/providers/index.ts +1 -1
- package/src/email/service.ts +1 -1
- package/src/gallery/default-gallery.ts +538 -0
- package/src/gallery/gallery-manifest.ts +5 -1
- package/src/influencer/client.ts +8 -6
- package/src/mcp/client.ts +1 -1
- package/src/media/avatar-router.ts +99 -0
- package/src/media/avatar-types.ts +60 -0
- package/src/media/managed-avatar-client.ts +189 -0
- package/src/memory/app-migration.ts +114 -0
- package/src/memory/app-store.ts +11 -0
- package/src/memory/qdrant-client.ts +1 -1
- package/src/messaging/providers/slack/client.ts +12 -2
- package/src/messaging/providers/sms/adapter.ts +6 -10
- package/src/migrations/data-layout.ts +8 -1
- package/src/oauth/token-persistence.ts +9 -6
- package/src/runtime/assistant-scope.ts +5 -0
- package/src/runtime/auth/route-policy.ts +4 -0
- package/src/runtime/channel-readiness-service.ts +9 -4
- package/src/runtime/gateway-internal-client.ts +11 -3
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/invite-redemption-service.ts +23 -13
- package/src/runtime/middleware/twilio-validation.ts +2 -2
- package/src/runtime/routes/app-routes.ts +131 -3
- package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/runtime/routes/slack-share-routes.ts +235 -0
- package/src/runtime/routes/twilio-routes.ts +47 -34
- package/src/schedule/integration-status.ts +2 -3
- package/src/security/token-manager.ts +11 -3
- package/src/tools/apps/executors.ts +116 -8
- package/src/tools/browser/browser-manager.ts +30 -2
- package/src/tools/browser/chrome-cdp.ts +31 -3
- package/src/tools/credentials/vault.ts +9 -7
- package/src/tools/executor.ts +4 -0
- package/src/tools/system/avatar-generator.ts +55 -34
- package/src/twitter/client.ts +1 -1
- package/src/twitter/oauth-client.ts +31 -43
- package/src/twitter/router.ts +25 -23
- package/src/util/platform.ts +5 -0
- 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
|
+
});
|