@vellumai/assistant 0.10.10-dev.202607171828.fc2f5d5 → 0.10.10-dev.202607172024.ca75f7f

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/attachment-upload-trusted-source.test.ts +8 -1
  3. package/src/__tests__/attachments-store-heic-normalize.test.ts +14 -1
  4. package/src/__tests__/conversation-error.test.ts +18 -0
  5. package/src/__tests__/image-conversion.test.ts +70 -0
  6. package/src/__tests__/image-recovery-hook.test.ts +83 -0
  7. package/src/__tests__/list-messages-attachments.test.ts +2 -2
  8. package/src/__tests__/persist-unsendable-image.test.ts +86 -2
  9. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  10. package/src/__tests__/subagent-spawn-and-await.test.ts +32 -0
  11. package/src/__tests__/subagent-tool-gate-mode.test.ts +248 -1
  12. package/src/cli/lib/__tests__/install-from-github.test.ts +96 -4
  13. package/src/cli/lib/__tests__/install-plugin-dependencies.test.ts +136 -0
  14. package/src/cli/lib/install-from-github.ts +29 -13
  15. package/src/cli/lib/install-from-platform.ts +1 -1
  16. package/src/cli/lib/install-plugin-dependencies.ts +184 -0
  17. package/src/cli/lib/upgrade-plugin.ts +7 -1
  18. package/src/config/feature-flag-registry.json +9 -0
  19. package/src/daemon/conversation-error.ts +14 -0
  20. package/src/daemon/conversation-tool-setup.ts +91 -6
  21. package/src/daemon/conversation.ts +11 -0
  22. package/src/daemon/tool-setup-types.ts +6 -0
  23. package/src/live-voice/live-voice-session.ts +7 -1
  24. package/src/persistence/attachments-store.ts +5 -4
  25. package/src/plugins/defaults/image-recovery/detect.ts +19 -3
  26. package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +9 -1
  27. package/src/plugins/defaults/image-recovery/recover.ts +64 -21
  28. package/src/plugins/ensure-shared-dep-links.ts +8 -6
  29. package/src/plugins/plugin-tree-walk.ts +16 -4
  30. package/src/plugins/source-fingerprint.ts +0 -4
  31. package/src/plugins/user-loader.ts +5 -4
  32. package/src/subagent/manager.ts +7 -0
  33. package/src/subagent/types.ts +9 -0
  34. package/src/util/image-conversion.ts +93 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.10-dev.202607171828.fc2f5d5",
3
+ "version": "0.10.10-dev.202607172024.ca75f7f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,6 +23,13 @@ import type { RouteHandlerArgs } from "../runtime/routes/types.js";
23
23
  const SMALL_PNG_BASE64 =
24
24
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
25
25
 
26
+ // Non-image bytes: storage normalization sniffs image magic bytes and
27
+ // overrides a disagreeing declared MIME, so a declared-MIME assertion needs a
28
+ // payload that doesn't sniff as an image.
29
+ const FAKE_VIDEO_BASE64 = Buffer.from("fake matroska payload").toString(
30
+ "base64",
31
+ );
32
+
26
33
  const uploadRoute = ROUTES.find((r) => r.operationId === "attachment_upload")!;
27
34
 
28
35
  function makeUploadArgs(
@@ -59,7 +66,7 @@ describe("attachment upload — trustedSource flag", () => {
59
66
  {
60
67
  filename: "clip.mkv",
61
68
  mimeType: "video/x-matroska",
62
- data: SMALL_PNG_BASE64,
69
+ data: FAKE_VIDEO_BASE64,
63
70
  trustedSource: true,
64
71
  },
65
72
  "svc_gateway",
@@ -31,7 +31,10 @@ mock.module("../util/image-conversion.js", () => ({
31
31
  normalizeImageBase64: (mimeType: string, dataBase64: string) =>
32
32
  mimeType === "image/heic"
33
33
  ? { mimeType: "image/jpeg", dataBase64: FAKE_JPEG_B64, converted: true }
34
- : { mimeType, dataBase64, converted: false },
34
+ : mimeType === "image/mislabeled"
35
+ ? // Sniff-corrected declared MIME: payload untouched, converted false.
36
+ { mimeType: "image/png", dataBase64, converted: false }
37
+ : { mimeType, dataBase64, converted: false },
35
38
  }));
36
39
 
37
40
  import {
@@ -131,6 +134,16 @@ describe("HEIC upload normalization wiring", () => {
131
134
  expect(stored.sizeBytes).toBe(HEIC_BYTES.length);
132
135
  });
133
136
 
137
+ test("uploadAttachment (base64) propagates a MIME-only sniff correction", () => {
138
+ const stored = uploadAttachment("photo.png", "image/mislabeled", HEIC_B64);
139
+
140
+ // Corrected MIME is stored; filename and payload stay verbatim.
141
+ expect(stored.originalFilename).toBe("photo.png");
142
+ expect(stored.mimeType).toBe("image/png");
143
+ const row = getAttachmentById(stored.id);
144
+ expect(row?.dataBase64).toBe(HEIC_B64);
145
+ });
146
+
134
147
  test("attachInlineAttachmentToMessage normalizes only when opted in", async () => {
135
148
  const conv = createConversation();
136
149
  const msg = await addMessage(
@@ -379,6 +379,24 @@ describe("classifyConversationError", () => {
379
379
  expect(result.retryable).toBe(false);
380
380
  });
381
381
 
382
+ it("classifies Anthropic 400 media-type mismatch as image_media_type_mismatch (non-retryable)", () => {
383
+ // The rejection for an image whose declared media type disagrees with
384
+ // its bytes (e.g. a JPEG renamed to .png before upload). It must route
385
+ // to the image-recovery classification so the relabel path fires, not
386
+ // loop through the generic PROVIDER_API branch.
387
+ const err = new ProviderError(
388
+ 'Anthropic API error (400): 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.50.content.0.image.source.base64.data: Image does not match the provided media type image/png"},"request_id":"req_011Ccs00000000000000000"}',
389
+ "anthropic",
390
+ 400,
391
+ );
392
+ const result = classifyConversationError(err, baseCtx);
393
+ expect(result.code).toBe("IMAGE_TOO_LARGE");
394
+ expect(result.errorCategory).toBe("image_media_type_mismatch");
395
+ expect(result.retryable).toBe(false);
396
+ expect(result.userMessage).not.toContain("invalid_request_error");
397
+ expect(result.userMessage.toLowerCase()).toContain("image");
398
+ });
399
+
382
400
  it("classifies Anthropic 400 'Could not process image' as image_unprocessable (non-retryable)", () => {
383
401
  // The rejection Anthropic returns for images below its minimum size
384
402
  // floor (e.g. a 16×14 px upload). It must route to the image-recovery
@@ -14,6 +14,8 @@ import {
14
14
  jpegFilenameFor,
15
15
  normalizeImageBase64,
16
16
  normalizeImageBytes,
17
+ sniffBase64ImageMimeType,
18
+ sniffImageMimeType,
17
19
  } from "../util/image-conversion.js";
18
20
  import {
19
21
  fakeHeifHeaderBytes,
@@ -69,6 +71,48 @@ describe("jpegFilenameFor", () => {
69
71
  });
70
72
  });
71
73
 
74
+ const JPEG_HEADER_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
75
+ const GIF_HEADER_BYTES = Buffer.from("GIF89a\x01\x00\x01\x00", "latin1");
76
+ const WEBP_HEADER_BYTES = Buffer.concat([
77
+ Buffer.from("RIFF"),
78
+ Buffer.from([0x24, 0x00, 0x00, 0x00]),
79
+ Buffer.from("WEBPVP8 "),
80
+ ]);
81
+
82
+ describe("sniffImageMimeType", () => {
83
+ test("identifies PNG, JPEG, GIF, and WebP signatures", () => {
84
+ expect(sniffImageMimeType(PNG_1PX_BYTES)).toBe("image/png");
85
+ expect(sniffImageMimeType(JPEG_HEADER_BYTES)).toBe("image/jpeg");
86
+ expect(sniffImageMimeType(GIF_HEADER_BYTES)).toBe("image/gif");
87
+ expect(sniffImageMimeType(WEBP_HEADER_BYTES)).toBe("image/webp");
88
+ });
89
+
90
+ test("returns null for unrecognized or truncated content", () => {
91
+ expect(sniffImageMimeType(Buffer.from("plain text content"))).toBeNull();
92
+ expect(sniffImageMimeType(Buffer.alloc(0))).toBeNull();
93
+ expect(sniffImageMimeType(fakeHeifHeaderBytes())).toBeNull();
94
+ // RIFF container that is not WebP (e.g. WAV audio).
95
+ expect(
96
+ sniffImageMimeType(
97
+ Buffer.concat([
98
+ Buffer.from("RIFF"),
99
+ Buffer.from([0x24, 0x00, 0x00, 0x00]),
100
+ Buffer.from("WAVEfmt "),
101
+ ]),
102
+ ),
103
+ ).toBeNull();
104
+ });
105
+
106
+ test("base64 variant sniffs from the encoded head", () => {
107
+ expect(sniffBase64ImageMimeType(PNG_1PX_BYTES.toString("base64"))).toBe(
108
+ "image/png",
109
+ );
110
+ expect(
111
+ sniffBase64ImageMimeType(Buffer.from("not an image").toString("base64")),
112
+ ).toBeNull();
113
+ });
114
+ });
115
+
72
116
  describe("normalizeImageBytes passthrough", () => {
73
117
  test("non-HEIF bytes pass through untouched", () => {
74
118
  const result = normalizeImageBytes("image/png", PNG_1PX_BYTES);
@@ -96,6 +140,32 @@ describe("normalizeImageBase64 passthrough", () => {
96
140
  });
97
141
  });
98
142
 
143
+ describe("declared-MIME correction from sniffed bytes", () => {
144
+ test("normalizeImageBytes relabels a mislabeled image, bytes untouched", () => {
145
+ // A JPEG renamed to .png arrives declared as image/png; providers reject
146
+ // the mismatch, so the sniffed format wins.
147
+ const result = normalizeImageBytes("image/png", JPEG_HEADER_BYTES);
148
+ expect(result.converted).toBe(false);
149
+ expect(result.mimeType).toBe("image/jpeg");
150
+ expect(result.bytes).toBe(JPEG_HEADER_BYTES);
151
+ });
152
+
153
+ test("normalizeImageBase64 relabels a mislabeled image, payload untouched", () => {
154
+ const b64 = PNG_1PX_BYTES.toString("base64");
155
+ const result = normalizeImageBase64("image/jpeg", b64);
156
+ expect(result.converted).toBe(false);
157
+ expect(result.mimeType).toBe("image/png");
158
+ expect(result.dataBase64).toBe(b64);
159
+ });
160
+
161
+ test("unrecognized bytes keep the declared MIME", () => {
162
+ const bytes = Buffer.from("plain text content");
163
+ const result = normalizeImageBytes("text/plain", bytes);
164
+ expect(result.mimeType).toBe("text/plain");
165
+ expect(result.bytes).toBe(bytes);
166
+ });
167
+ });
168
+
99
169
  describe.skipIf(process.platform !== "darwin")(
100
170
  "real HEIC conversion (sips)",
101
171
  () => {
@@ -241,6 +241,89 @@ describe("image-recovery post-model-call hook — direct", () => {
241
241
  });
242
242
  });
243
243
 
244
+ test("media-type-mismatch rejection → relabels the mislabeled image and continues", async () => {
245
+ // GIVEN a stored history holding a normally-sized PNG declared as
246
+ // image/jpeg (a renamed file — clients derive the MIME from the
247
+ // extension), and the provider rejection that mismatch produces.
248
+ const pngData = makePngBase64(1024, 768);
249
+ const mislabeled: ContentBlock = {
250
+ type: "image",
251
+ source: { type: "base64", media_type: "image/jpeg", data: pngData },
252
+ };
253
+ const conv = createConversation();
254
+ await addMessage(conv.id, "user", JSON.stringify([mislabeled]), {
255
+ skipIndexing: true,
256
+ });
257
+ const ctx = makePostModelCallCtx({
258
+ conversationId: conv.id,
259
+ messages: [{ role: "user", content: [structuredClone(mislabeled)] }],
260
+ error: new Error(
261
+ 'Anthropic API error (400): 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.0.content.0.image.source.base64.data: Image does not match the provided media type image/jpeg"}}',
262
+ ),
263
+ });
264
+
265
+ // WHEN the hook runs.
266
+ await postModelCall(ctx);
267
+
268
+ // THEN it retries with the image relabeled to its sniffed type — bytes
269
+ // kept — both in the working history and in the stored row, so the
270
+ // mismatch cannot re-reject on this or any later turn.
271
+ expect(ctx.decision).toBe("continue");
272
+ const workingImage = ctx.messages[0].content[0] as Extract<
273
+ ContentBlock,
274
+ { type: "image" }
275
+ >;
276
+ expect(workingImage.source).toMatchObject({
277
+ media_type: "image/png",
278
+ data: pngData,
279
+ });
280
+ const storedImage = getMessages(conv.id)[0].content[0] as Extract<
281
+ ContentBlock,
282
+ { type: "image" }
283
+ >;
284
+ expect(storedImage.source).toMatchObject({
285
+ media_type: "image/png",
286
+ data: pngData,
287
+ });
288
+ });
289
+
290
+ test("media-type-mismatch on an unrecognized format → no retry, error surfaces", async () => {
291
+ // GIVEN a mismatch rejection for an image whose actual format the sniffer
292
+ // cannot identify (a renamed BMP: "BM" magic, not in the sniff set), so no
293
+ // relabel is possible and the image is within size limits.
294
+ const bmpData = Buffer.concat([
295
+ Buffer.from("BM"),
296
+ Buffer.alloc(64, 0),
297
+ ]).toString("base64");
298
+ const unrecognized: ContentBlock = {
299
+ type: "image",
300
+ source: { type: "base64", media_type: "image/png", data: bmpData },
301
+ };
302
+ const ctx = makePostModelCallCtx({
303
+ messages: [{ role: "user", content: [structuredClone(unrecognized)] }],
304
+ error: new Error(
305
+ 'Anthropic API error (400): 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.0.content.0.image.source.base64.data: Image does not match the provided media type image/png"}}',
306
+ ),
307
+ });
308
+
309
+ // WHEN the hook runs.
310
+ await postModelCall(ctx);
311
+
312
+ // THEN it does not retry (nothing could be corrected) and leaves the
313
+ // history untouched, so the original error surfaces instead of a false
314
+ // "corrected — resend" loop.
315
+ expect(ctx.decision).toBe("stop");
316
+ expect(isImageRecoveryAttempted(ctx.conversationId)).toBe(false);
317
+ const image = ctx.messages[0].content[0] as Extract<
318
+ ContentBlock,
319
+ { type: "image" }
320
+ >;
321
+ expect(image.source).toMatchObject({
322
+ media_type: "image/png",
323
+ data: bmpData,
324
+ });
325
+ });
326
+
244
327
  test("durably persists the downgrade so the image cannot resurface", async () => {
245
328
  // GIVEN a conversation whose stored history holds an oversized image.
246
329
  const conv = createConversation();
@@ -172,7 +172,7 @@ describe("handleListMessages attachments", () => {
172
172
  "user",
173
173
  JSON.stringify([{ type: "text", text: "here are files" }]),
174
174
  );
175
- const imgStored = uploadAttachment("photo.jpg", "image/jpeg", IMAGE_BASE64);
175
+ const imgStored = uploadAttachment("photo.png", "image/png", IMAGE_BASE64);
176
176
  const docStored = uploadAttachment(
177
177
  "doc.pdf",
178
178
  "application/pdf",
@@ -187,7 +187,7 @@ describe("handleListMessages attachments", () => {
187
187
  const attachments = body.messages[0].attachments!;
188
188
  expect(attachments).toHaveLength(2);
189
189
 
190
- const imgAtt = attachments.find((a) => a.mimeType === "image/jpeg");
190
+ const imgAtt = attachments.find((a) => a.mimeType === "image/png");
191
191
  const docAtt = attachments.find((a) => a.mimeType === "application/pdf");
192
192
  expect(imgAtt!.data).toBe(IMAGE_BASE64);
193
193
  expect(docAtt!.data).toBeUndefined();
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { beforeEach, describe, expect, test } from "bun:test";
16
16
 
17
+ import { uploadAttachment } from "../persistence/attachments-store.js";
17
18
  import {
18
19
  addMessage,
19
20
  createConversation,
@@ -83,10 +84,10 @@ function makePngBase64(width: number, height: number, padBytes = 0): string {
83
84
  return padBytes > 0 ? header + "A".repeat(padBytes) : header;
84
85
  }
85
86
 
86
- function imageBlock(data: string): ContentBlock {
87
+ function imageBlock(data: string, mediaType = "image/png"): ContentBlock {
87
88
  return {
88
89
  type: "image",
89
- source: { type: "base64", media_type: "image/png", data },
90
+ source: { type: "base64", media_type: mediaType, data },
90
91
  };
91
92
  }
92
93
 
@@ -286,6 +287,41 @@ describe("persistUnsendableImageDowngrades", () => {
286
287
  );
287
288
  });
288
289
 
290
+ /** A stored image whose declared media type disagrees with its bytes (e.g.
291
+ * a JPEG renamed to .png before upload) is relabeled in place — bytes kept,
292
+ * media_type corrected — so it stops re-rejecting on every later turn. */
293
+ test("relabels a mislabeled image instead of removing it", async () => {
294
+ // GIVEN a normally-sized PNG stored with a declared type of image/jpeg
295
+ const conv = createConversation();
296
+ const pngData = makePngBase64(1024, 768);
297
+ await addMessage(
298
+ conv.id,
299
+ "user",
300
+ JSON.stringify([imageBlock(pngData, "image/jpeg")]),
301
+ { skipIndexing: true },
302
+ );
303
+
304
+ // WHEN the downgrade is persisted
305
+ const rewritten = persistUnsendableImageDowngrades(conv.id);
306
+
307
+ // THEN the image survives with its media type corrected to the sniffed one
308
+ expect(rewritten).toBe(1);
309
+ const [content] = storedContent(conv.id);
310
+ const image = content.find((b) => b.type === "image") as Extract<
311
+ ContentBlock,
312
+ { type: "image" }
313
+ >;
314
+ expect(image).toBeDefined();
315
+ expect(image.source).toMatchObject({
316
+ type: "base64",
317
+ media_type: "image/png",
318
+ data: pngData,
319
+ });
320
+
321
+ // AND a second run is a no-op (the corrected block no longer mismatches)
322
+ expect(persistUnsendableImageDowngrades(conv.id)).toBe(0);
323
+ });
324
+
289
325
  /** Re-running after a rewrite is a safe no-op (no image blocks remain). */
290
326
  test("is idempotent — a second run rewrites nothing", async () => {
291
327
  // GIVEN a conversation whose oversized image has already been downgraded
@@ -340,4 +376,52 @@ describe("unsendableImageReplacement", () => {
340
376
  const replacement = unsendableImageReplacement(undersized);
341
377
  expect(replacement?.type).toBe("text");
342
378
  });
379
+
380
+ /** A within-limits image whose declared media type disagrees with its bytes
381
+ * is relabeled with the sniffed type instead of being noted out. */
382
+ test("relabels a mislabeled image with its sniffed media type", () => {
383
+ const pngData = makePngBase64(1024, 768);
384
+ const mislabeled = imageBlock(pngData, "image/jpeg") as Extract<
385
+ ContentBlock,
386
+ { type: "image" }
387
+ >;
388
+ const replacement = unsendableImageReplacement(mislabeled);
389
+ expect(replacement).toMatchObject({
390
+ type: "image",
391
+ source: { type: "base64", media_type: "image/png", data: pngData },
392
+ });
393
+ });
394
+
395
+ /** Relabeling a persisted (workspace_ref) image keeps the reference shape —
396
+ * inlining it would bake the full payload into the stored message row. */
397
+ test("relabels a mislabeled workspace_ref without inlining the payload", () => {
398
+ const pngData = makePngBase64(1024, 768);
399
+ const stored = uploadAttachment("photo.png", "image/png", pngData);
400
+ const mislabeledRef = {
401
+ type: "image",
402
+ source: {
403
+ type: "workspace_ref",
404
+ media_type: "image/jpeg",
405
+ attachmentId: stored.id,
406
+ sizeBytes: Buffer.from(pngData, "base64").length,
407
+ },
408
+ } as Extract<ContentBlock, { type: "image" }>;
409
+
410
+ const replacement = unsendableImageReplacement(mislabeledRef);
411
+
412
+ expect(replacement).toMatchObject({
413
+ type: "image",
414
+ source: {
415
+ type: "workspace_ref",
416
+ media_type: "image/png",
417
+ attachmentId: stored.id,
418
+ },
419
+ });
420
+ // AND the corrected reference no longer matches on a second pass
421
+ expect(
422
+ unsendableImageReplacement(
423
+ replacement as Extract<ContentBlock, { type: "image" }>,
424
+ ),
425
+ ).toBeNull();
426
+ });
343
427
  });
@@ -75,6 +75,7 @@ const BASELINE: Record<string, readonly string[]> = {
75
75
  "../../../agent/image-optimize.js",
76
76
  "../../../context/image-dimensions.js",
77
77
  "../../../persistence/conversation-crud.js",
78
+ "../../../util/image-conversion.js",
78
79
  "../../../util/logger.js",
79
80
  ],
80
81
  memory: [
@@ -52,6 +52,8 @@ let nextConversationConfig: FakeConversationConfig = {};
52
52
  let runLoopInvoked = false;
53
53
  /** The first user message persisted by the most recent FakeConversation. */
54
54
  let lastPersistedUserMessage: string | undefined;
55
+ /** Records `setSubagentDenySideEffects` on the most recent FakeConversation. */
56
+ let lastDenySideEffects: boolean | undefined;
55
57
 
56
58
  class FakeConversation {
57
59
  messages: Message[];
@@ -92,6 +94,9 @@ class FakeConversation {
92
94
  setAssistantId() {}
93
95
  setEnabledPlugins() {}
94
96
  setSubagentAllowedTools() {}
97
+ setSubagentDenySideEffects(deny: boolean) {
98
+ lastDenySideEffects = deny;
99
+ }
95
100
  setPreactivatedSkillIds() {}
96
101
  getCurrentSystemPrompt() {
97
102
  return "system";
@@ -214,6 +219,33 @@ function registerFakeParent(parentConversationId: string): {
214
219
  }
215
220
 
216
221
  describe("SubagentManager.spawnAndAwait", () => {
222
+ // Reset outside the test body so TypeScript does not narrow the module var to
223
+ // `undefined` across the opaque spawnAndAwait call.
224
+ beforeEach(() => {
225
+ lastDenySideEffects = undefined;
226
+ });
227
+
228
+ test("wires denySideEffectTools onto the subagent conversation (read-only)", async () => {
229
+ nextConversationConfig = {};
230
+
231
+ const manager = new SubagentManager();
232
+ await manager.spawnAndAwait(
233
+ makeConfig({ denySideEffectTools: true }),
234
+ () => {},
235
+ );
236
+
237
+ expect(lastDenySideEffects).toBe(true);
238
+ });
239
+
240
+ test("leaves denySideEffectTools off by default", async () => {
241
+ nextConversationConfig = {};
242
+
243
+ const manager = new SubagentManager();
244
+ await manager.spawnAndAwait(makeConfig(), () => {});
245
+
246
+ expect(lastDenySideEffects).toBeUndefined();
247
+ });
248
+
217
249
  test("resolves to the child's final assistant text", async () => {
218
250
  nextConversationConfig = {
219
251
  messages: [