@vellumai/assistant 0.11.4-dev.202608202309.2fd95bc → 0.11.4-dev.202608210133.5377df0

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 (48) hide show
  1. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +0 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +10 -3
  4. package/src/__tests__/channel-reply-delivery.test.ts +15 -1
  5. package/src/__tests__/conversation-error.test.ts +17 -0
  6. package/src/__tests__/file-write-tool.test.ts +4 -2
  7. package/src/__tests__/host-shell-tool.test.ts +23 -4
  8. package/src/__tests__/live-workspace-guard.test.ts +75 -0
  9. package/src/__tests__/mcp-abort-signal.test.ts +1 -1
  10. package/src/__tests__/mcp-client-auth.test.ts +1 -1
  11. package/src/__tests__/mcp-tool-annotations-risk.test.ts +1 -1
  12. package/src/__tests__/media-resolve-image-validation.test.ts +310 -0
  13. package/src/__tests__/platform.test.ts +16 -1
  14. package/src/__tests__/plugin-api-shim.test.ts +5 -0
  15. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  16. package/src/cli/lib/__tests__/toggle-plugin.test.ts +2 -1
  17. package/src/daemon/conversation-error.ts +13 -0
  18. package/src/daemon/conversation-messaging.ts +22 -2
  19. package/src/daemon/trust-context.ts +3 -3
  20. package/src/daemon/unsendable-image-notice.ts +76 -0
  21. package/src/ipc/__tests__/socket-path.test.ts +15 -7
  22. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +1 -1
  23. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +31 -0
  24. package/src/messaging/providers/channel-transport.ts +33 -0
  25. package/src/messaging/providers/index.ts +21 -3
  26. package/src/messaging/providers/slack/send.test.ts +6 -9
  27. package/src/messaging/providers/slack/send.ts +32 -20
  28. package/src/messaging/providers/slack/transport.ts +11 -1
  29. package/src/notifications/adapters/slack.ts +10 -5
  30. package/src/persistence/conversation-crud.ts +9 -18
  31. package/src/persistence/conversation-types.ts +22 -0
  32. package/src/plugin-api/__tests__/import-graph-partial-mock.test.ts +67 -0
  33. package/src/plugin-api/index.ts +6 -0
  34. package/src/plugin-api/system-card.ts +36 -0
  35. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +361 -110
  36. package/src/plugins/defaults/image-fallback/__tests__/vision-recovery.test.ts +4 -0
  37. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +58 -0
  38. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +64 -23
  39. package/src/plugins/defaults/image-recovery/detect.ts +24 -1
  40. package/src/providers/gemini/client.ts +5 -2
  41. package/src/providers/media-resolve.ts +176 -15
  42. package/src/runtime/channel-reply-delivery.ts +42 -11
  43. package/src/runtime/routes/__tests__/system-card-turn-termination.test.ts +107 -0
  44. package/src/runtime/routes/canned-message-complete.ts +68 -29
  45. package/src/runtime/routes/guardian-approval-interception.ts +5 -6
  46. package/src/runtime/routes/inbound-message-handler.ts +7 -5
  47. package/src/util/image-conversion.ts +40 -9
  48. package/src/util/platform.ts +93 -10
@@ -175,8 +175,6 @@ export const ChannelReplyPayloadSchema = z.object({
175
175
  ephemeral: z.boolean().optional(),
176
176
  /** Slack user ID — required when `ephemeral` is true. */
177
177
  user: z.string().optional(),
178
- /** When provided, update an existing message instead of posting a new one. */
179
- messageTs: z.string().optional(),
180
178
  /** When true, the daemon generates Block Kit blocks from the text before delivery. */
181
179
  useBlocks: z.boolean().optional(),
182
180
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608202309.2fd95bc",
3
+ "version": "0.11.4-dev.202608210133.5377df0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -160,6 +160,13 @@ function cachedPrefixBlock(text: string): ContentBlock {
160
160
  } as unknown as ContentBlock;
161
161
  }
162
162
 
163
+ /**
164
+ * A 1x1 PNG. The send boundary validates image bytes before serialization, so
165
+ * an image fixture has to carry a real signature to reach the wire.
166
+ */
167
+ const PNG_B64 =
168
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==";
169
+
163
170
  const sampleTools: ToolDefinition[] = [
164
171
  {
165
172
  name: "file_read",
@@ -2754,7 +2761,7 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
2754
2761
  source: {
2755
2762
  type: "base64",
2756
2763
  media_type: "image/png",
2757
- data: "iVBOR",
2764
+ data: PNG_B64,
2758
2765
  },
2759
2766
  },
2760
2767
  { type: "text", text: "extra error detail" },
@@ -2806,7 +2813,7 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
2806
2813
  source: {
2807
2814
  type: "base64",
2808
2815
  media_type: "image/png",
2809
- data: "iVBOR",
2816
+ data: PNG_B64,
2810
2817
  },
2811
2818
  },
2812
2819
  ],
@@ -2853,7 +2860,7 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
2853
2860
  source: {
2854
2861
  type: "base64",
2855
2862
  media_type: "image/png",
2856
- data: "iVBOR",
2863
+ data: PNG_B64,
2857
2864
  },
2858
2865
  },
2859
2866
  ],
@@ -58,6 +58,19 @@ const renderedHistoryContentQueue: RenderedHistoryStub[] = [];
58
58
 
59
59
  let deliveryFailAtIndex = -1;
60
60
 
61
+ const editCalls: { callbackUrl: string; target: Record<string, unknown> }[] =
62
+ [];
63
+
64
+ mock.module("../messaging/providers/index.js", () => ({
65
+ editChannelMessage: async (
66
+ callbackUrl: string,
67
+ target: Record<string, unknown>,
68
+ ) => {
69
+ editCalls.push({ callbackUrl, target });
70
+ return { ok: true };
71
+ },
72
+ }));
73
+
61
74
  mock.module("../runtime/gateway-client.js", () => ({
62
75
  deliverChannelReply: async (
63
76
  callbackUrl: string,
@@ -784,8 +797,9 @@ describe("channel-reply-delivery", () => {
784
797
  assistantId: undefined,
785
798
  ephemeral: undefined,
786
799
  user: undefined,
787
- messageTs: "1700000000.000055",
788
800
  });
801
+ // Attachments post as new messages, so nothing is edited on this path.
802
+ expect(editCalls).toHaveLength(0);
789
803
  expect(seenTs).toEqual(["1700000000.000055"]);
790
804
  });
791
805
 
@@ -521,6 +521,23 @@ describe("classifyConversationError", () => {
521
521
  expect(result.userMessage.toLowerCase()).toContain("image");
522
522
  });
523
523
 
524
+ it("classifies an OpenAI-compatible 400 for an unreadable image as image_unsupported_format (non-retryable)", () => {
525
+ // The rejection an OpenAI-compatible endpoint returns when any image in
526
+ // the request is not one of the formats it decodes (a HEIC photo renamed
527
+ // to .png, say). It names no index, so the whole turn dies unless it is
528
+ // classified for the image-recovery path with actionable copy.
529
+ const err = new ProviderError(
530
+ "The image data you provided does not represent a valid image. Please check your input and try again with one of the supported image formats: [image/jpeg, image/png, image/gif, image/webp]",
531
+ "openai",
532
+ 400,
533
+ );
534
+ const result = classifyConversationError(err, baseCtx);
535
+ expect(result.code).toBe("IMAGE_TOO_LARGE");
536
+ expect(result.errorCategory).toBe("image_unsupported_format");
537
+ expect(result.retryable).toBe(false);
538
+ expect(result.userMessage.toLowerCase()).toContain("image");
539
+ });
540
+
524
541
  it("does not steal generic 400s that happen to mention 'image'", () => {
525
542
  const err = new ProviderError(
526
543
  "invalid request: image source is missing",
@@ -76,8 +76,10 @@ beforeEach(() => {
76
76
  enqueueCalls.length = 0;
77
77
  enqueueThrows = false;
78
78
  // Reset to a stable tmp path so the sandbox tests (which don't use pkb/)
79
- // deterministically land outside any configured PKB root.
80
- process.env.VELLUM_WORKSPACE_DIR = tmpdir();
79
+ // deterministically land outside any configured PKB root. A subdirectory
80
+ // rather than tmpdir() itself, so vellumRoot() (the parent) also stays
81
+ // inside the temp directory for the live-workspace guard.
82
+ process.env.VELLUM_WORKSPACE_DIR = join(tmpdir(), "file-write-tool-ws");
81
83
  });
82
84
 
83
85
  afterEach(() => {
@@ -678,6 +678,14 @@ describe("host_bash — spawn error handling", () => {
678
678
  // ---------------------------------------------------------------------------
679
679
 
680
680
  describe("host_bash — proxy delegation", () => {
681
+ // Under tmpdir so the live-workspace guard accepts it as a workspace.
682
+ const FAKE_INSTANCE_WS = join(
683
+ tmpdir(),
684
+ "vellum-instance",
685
+ ".vellum",
686
+ "workspace",
687
+ );
688
+
681
689
  const ROUTING_ENV_KEYS = [
682
690
  "VELLUM_WORKSPACE_DIR",
683
691
  "VELLUM_DATA_DIR",
@@ -850,6 +858,11 @@ describe("host_bash — proxy delegation", () => {
850
858
  for (const key of ROUTING_ENV_KEYS) {
851
859
  delete process.env[key];
852
860
  }
861
+ // With VELLUM_WORKSPACE_DIR unset, loadConfig resolves the real ~/.vellum
862
+ // fallback (read-only here), so bypass the live-workspace guard for it.
863
+ const originalAllowRealWorkspace =
864
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
865
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS = "1";
853
866
 
854
867
  try {
855
868
  const proxyResult: ToolExecutionResult = {
@@ -870,14 +883,20 @@ describe("host_bash — proxy delegation", () => {
870
883
  __REVEAL_NONCE: expect.any(String),
871
884
  });
872
885
  } finally {
886
+ if (originalAllowRealWorkspace === undefined) {
887
+ delete process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
888
+ } else {
889
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS =
890
+ originalAllowRealWorkspace;
891
+ }
873
892
  restoreEnv(envSnapshot);
874
893
  }
875
894
  });
876
895
 
877
896
  test("propagates daemon routing env vars to proxy for nested assistant CLI calls", async () => {
878
897
  const envSnapshot = captureEnv(ROUTING_ENV_KEYS);
879
- process.env.VELLUM_WORKSPACE_DIR = "/tmp/vellum-instance/.vellum/workspace";
880
- process.env.VELLUM_DATA_DIR = "/tmp/vellum-instance/.vellum/workspace/data";
898
+ process.env.VELLUM_WORKSPACE_DIR = FAKE_INSTANCE_WS;
899
+ process.env.VELLUM_DATA_DIR = join(FAKE_INSTANCE_WS, "data");
881
900
  process.env.VELLUM_ENVIRONMENT = "local";
882
901
  process.env.INTERNAL_GATEWAY_BASE_URL = "http://127.0.0.1:7830";
883
902
 
@@ -896,8 +915,8 @@ describe("host_bash — proxy delegation", () => {
896
915
  expect(result).toBe(proxyResult);
897
916
  expect(calls.length).toBe(1);
898
917
  expect(calls[0].input.env).toEqual({
899
- VELLUM_WORKSPACE_DIR: "/tmp/vellum-instance/.vellum/workspace",
900
- VELLUM_DATA_DIR: "/tmp/vellum-instance/.vellum/workspace/data",
918
+ VELLUM_WORKSPACE_DIR: FAKE_INSTANCE_WS,
919
+ VELLUM_DATA_DIR: join(FAKE_INSTANCE_WS, "data"),
901
920
  VELLUM_ENVIRONMENT: "local",
902
921
  INTERNAL_GATEWAY_BASE_URL: "http://127.0.0.1:7830",
903
922
  __CONVERSATION_ID: "test-conversation",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Tests for the live-workspace guard in util/platform.ts: a test process must
3
+ * never resolve the workspace to a directory outside os.tmpdir().
4
+ */
5
+
6
+ import { rmSync, symlinkSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+
11
+ import { getWorkspaceDir } from "../util/platform.js";
12
+
13
+ const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
14
+ const originalAllowReal = process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
15
+
16
+ afterEach(() => {
17
+ process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
18
+ if (originalAllowReal === undefined) {
19
+ delete process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
20
+ } else {
21
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS = originalAllowReal;
22
+ }
23
+ });
24
+
25
+ describe("live-workspace guard", () => {
26
+ test("allows the preload's tmpdir workspace", () => {
27
+ expect(getWorkspaceDir()).toBe(process.env.VELLUM_WORKSPACE_DIR!);
28
+ });
29
+
30
+ test("allows a not-yet-created nested path under tmpdir", () => {
31
+ const dir = join(
32
+ tmpdir(),
33
+ "vellum-guard-nonexistent",
34
+ "nested",
35
+ "workspace",
36
+ );
37
+ process.env.VELLUM_WORKSPACE_DIR = dir;
38
+ expect(getWorkspaceDir()).toBe(dir);
39
+ });
40
+
41
+ test("refuses a non-tmpdir workspace in a test process", () => {
42
+ delete process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
43
+ process.env.VELLUM_WORKSPACE_DIR = join(homedir(), "vellum-guard-live");
44
+ expect(() => getWorkspaceDir()).toThrow(/Refusing to use/);
45
+ });
46
+
47
+ test("refuses a tmpdir symlink that resolves outside tmpdir", () => {
48
+ delete process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
49
+ const link = join(tmpdir(), `vellum-guard-escape-${process.pid}`);
50
+ rmSync(link, { force: true });
51
+ symlinkSync(homedir(), link);
52
+ try {
53
+ process.env.VELLUM_WORKSPACE_DIR = link;
54
+ expect(() => getWorkspaceDir()).toThrow(/Refusing to use/);
55
+ } finally {
56
+ rmSync(link, { force: true });
57
+ }
58
+ });
59
+
60
+ test("VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS=1 bypasses the guard", () => {
61
+ const dir = join(homedir(), "vellum-guard-live-optout");
62
+ process.env.VELLUM_WORKSPACE_DIR = dir;
63
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS = "1";
64
+ expect(getWorkspaceDir()).toBe(dir);
65
+ });
66
+
67
+ test("opt-out is per-call, not cached: same dir throws once the var clears", () => {
68
+ const dir = join(homedir(), "vellum-guard-live-optout-cleared");
69
+ process.env.VELLUM_WORKSPACE_DIR = dir;
70
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS = "1";
71
+ expect(getWorkspaceDir()).toBe(dir);
72
+ delete process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
73
+ expect(() => getWorkspaceDir()).toThrow(/Refusing to use/);
74
+ });
75
+ });
@@ -12,7 +12,7 @@ mock.module("../config/env-registry.js", () => ({
12
12
  getIsContainerized: () => false,
13
13
  getIsPlatform: () => false,
14
14
  isPlatformRemote: () => false,
15
- getWorkspaceDirOverride: () => undefined,
15
+ getWorkspaceDirOverride: () => process.env.VELLUM_WORKSPACE_DIR,
16
16
  getBackupDirOverride: () => undefined,
17
17
  getBackupKeyPathOverride: () => undefined,
18
18
  getCpuLimit: () => undefined,
@@ -12,7 +12,7 @@ mock.module("../config/env-registry.js", () => ({
12
12
  getIsContainerized: () => false,
13
13
  getIsPlatform: () => false,
14
14
  isPlatformRemote: () => false,
15
- getWorkspaceDirOverride: () => undefined,
15
+ getWorkspaceDirOverride: () => process.env.VELLUM_WORKSPACE_DIR,
16
16
  getBackupDirOverride: () => undefined,
17
17
  getBackupKeyPathOverride: () => undefined,
18
18
  getCpuLimit: () => undefined,
@@ -12,7 +12,7 @@ mock.module("../config/env-registry.js", () => ({
12
12
  getIsContainerized: () => false,
13
13
  getIsPlatform: () => false,
14
14
  isPlatformRemote: () => false,
15
- getWorkspaceDirOverride: () => undefined,
15
+ getWorkspaceDirOverride: () => process.env.VELLUM_WORKSPACE_DIR,
16
16
  getBackupDirOverride: () => undefined,
17
17
  getBackupKeyPathOverride: () => undefined,
18
18
  getCpuLimit: () => undefined,
@@ -0,0 +1,310 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ unsendableImageCardText,
5
+ unsendableImageFilenames,
6
+ } from "../daemon/unsendable-image-notice.js";
7
+ import { createInlineAttachment } from "../persistence/attachments-store.js";
8
+ import {
9
+ addMessage,
10
+ createConversation,
11
+ } from "../persistence/conversation-crud.js";
12
+ import { getDb } from "../persistence/db-connection.js";
13
+ import { initializeDb } from "../persistence/db-init.js";
14
+ import {
15
+ isUnsendableImageSource,
16
+ resolveMediaReferences,
17
+ UNSENDABLE_IMAGE_FORMAT_NOTE,
18
+ } from "../providers/media-resolve.js";
19
+ import type { ContentBlock, Message } from "../providers/types.js";
20
+
21
+ await initializeDb();
22
+
23
+ function resetTables() {
24
+ const db = getDb();
25
+ db.run("DELETE FROM message_attachments");
26
+ db.run("DELETE FROM attachments");
27
+ db.run("DELETE FROM messages");
28
+ db.run("DELETE FROM conversations");
29
+ }
30
+
31
+ /** A 1x1 PNG: a real signature plus enough body to be a decodable image. */
32
+ const PNG_B64 =
33
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==";
34
+
35
+ /**
36
+ * A HEIC head (ISO BMFF `ftyp` + the `heic` brand) under a `.png` name, which
37
+ * is what a photo renamed by hand or exported by iOS arrives as.
38
+ */
39
+ const HEIC_B64 = Buffer.from([
40
+ 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00,
41
+ 0x00, 0x00, 0x00,
42
+ ]).toString("base64");
43
+
44
+ /** A PNG whose signature bytes are damaged, as a partial upload leaves it. */
45
+ const CORRUPT_PNG_B64 = Buffer.from([
46
+ 0x89, 0x50, 0x4e, 0x00, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
47
+ ]).toString("base64");
48
+
49
+ function userMessage(content: ContentBlock[]): Message {
50
+ return { role: "user", content };
51
+ }
52
+
53
+ function inlineImage(mediaType: string, data: string): ContentBlock {
54
+ return {
55
+ type: "image",
56
+ source: { type: "base64", media_type: mediaType, data },
57
+ };
58
+ }
59
+
60
+ async function storedImageRef(
61
+ filename: string,
62
+ mimeType: string,
63
+ dataBase64: string,
64
+ ): Promise<ContentBlock> {
65
+ const conv = createConversation();
66
+ const stored = await createInlineAttachment(
67
+ conv.id,
68
+ conv.createdAt,
69
+ filename,
70
+ mimeType,
71
+ dataBase64,
72
+ );
73
+ return {
74
+ type: "image",
75
+ source: {
76
+ type: "workspace_ref",
77
+ media_type: mimeType,
78
+ attachmentId: stored.id,
79
+ sizeBytes: stored.sizeBytes,
80
+ filename,
81
+ },
82
+ };
83
+ }
84
+
85
+ describe("resolveMediaReferences image validation", () => {
86
+ beforeEach(resetTables);
87
+
88
+ test("sends a valid inline image untouched", async () => {
89
+ // GIVEN a message carrying an inline PNG whose bytes match its declaration
90
+ const message = userMessage([inlineImage("image/png", PNG_B64)]);
91
+
92
+ // WHEN the send boundary resolves it
93
+ const [resolved] = await resolveMediaReferences([message]);
94
+
95
+ // THEN the block is unchanged, and the message is the same object (no
96
+ // allocation or disk read on the clean live-turn fast path)
97
+ expect(resolved).toBe(message);
98
+ });
99
+
100
+ test("drops the unreadable image from a batch and keeps the valid ones", async () => {
101
+ // GIVEN a batch of a valid PNG, a HEIC photo declared as a PNG, and a
102
+ // truncated PNG: the shape of the eight-image upload that lost a whole turn
103
+ const message = userMessage([
104
+ { type: "text", text: "what is in these?" },
105
+ inlineImage("image/png", PNG_B64),
106
+ inlineImage("image/png", HEIC_B64),
107
+ inlineImage("image/png", CORRUPT_PNG_B64),
108
+ ]);
109
+
110
+ // WHEN the send boundary resolves them
111
+ const [resolved] = await resolveMediaReferences([message]);
112
+
113
+ // THEN the valid image still goes to the model
114
+ expect(resolved.content[1]).toEqual(inlineImage("image/png", PNG_B64));
115
+
116
+ // AND each unreadable image is replaced by a note instead of failing the
117
+ // whole request
118
+ expect(resolved.content[2]).toEqual({
119
+ type: "text",
120
+ text: UNSENDABLE_IMAGE_FORMAT_NOTE,
121
+ });
122
+ expect(resolved.content[3]).toEqual({
123
+ type: "text",
124
+ text: UNSENDABLE_IMAGE_FORMAT_NOTE,
125
+ });
126
+ });
127
+
128
+ test("keeps a HEIC photo for a provider that decodes HEIF", async () => {
129
+ /**
130
+ * Gemini reads image/heic and image/heif directly, so an untranscoded HEIC
131
+ * photo must reach it as an image rather than as an omission note.
132
+ */
133
+ // GIVEN a HEIC photo declared as a PNG, as an untranscoded upload arrives
134
+ const message = userMessage([inlineImage("image/png", HEIC_B64)]);
135
+
136
+ // WHEN a provider that accepts HEIF resolves it
137
+ const [resolved] = await resolveMediaReferences([message], {
138
+ acceptsHeif: true,
139
+ });
140
+
141
+ // THEN the image survives, declared as the HEIF type its brand names
142
+ expect(resolved.content[0]).toEqual(inlineImage("image/heic", HEIC_B64));
143
+ });
144
+
145
+ test("relabels an inline image whose declared type is not its actual type", async () => {
146
+ // GIVEN PNG bytes declared as JPEG, which every provider rejects outright
147
+ const message = userMessage([inlineImage("image/jpeg", PNG_B64)]);
148
+
149
+ // WHEN the send boundary resolves it
150
+ const [resolved] = await resolveMediaReferences([message]);
151
+
152
+ // THEN the declaration is corrected to what the bytes are, payload intact
153
+ expect(resolved.content[0]).toEqual(inlineImage("image/png", PNG_B64));
154
+ });
155
+
156
+ test("drops a stored image whose bytes are not a readable format", async () => {
157
+ // GIVEN a persisted attachment holding HEIC bytes under a .png name,
158
+ // referenced by a reloaded message
159
+ const block = await storedImageRef("photo.png", "image/png", HEIC_B64);
160
+
161
+ // WHEN the send boundary resolves the reference
162
+ const [resolved] = await resolveMediaReferences([userMessage([block])]);
163
+
164
+ // THEN it is replaced by the note rather than hydrated and sent
165
+ expect(resolved.content[0]).toEqual({
166
+ type: "text",
167
+ text: UNSENDABLE_IMAGE_FORMAT_NOTE,
168
+ });
169
+ });
170
+
171
+ test("keeps the unavailable-attachment note for a reference with no bytes", async () => {
172
+ // GIVEN a reference to an attachment row that does not exist
173
+ const block: ContentBlock = {
174
+ type: "image",
175
+ source: {
176
+ type: "workspace_ref",
177
+ media_type: "image/png",
178
+ attachmentId: "missing-attachment",
179
+ sizeBytes: 1024,
180
+ filename: "gone.png",
181
+ },
182
+ };
183
+
184
+ // WHEN the send boundary resolves it
185
+ const [resolved] = await resolveMediaReferences([userMessage([block])]);
186
+
187
+ // THEN an unreadable payload is reported as unavailable, not as a bad
188
+ // format: the file may simply have been collected
189
+ expect(resolved.content[0]).toEqual({
190
+ type: "text",
191
+ text: "[Attachment unavailable: image could not be loaded]",
192
+ });
193
+ });
194
+
195
+ test("validates images nested in a tool result", async () => {
196
+ // GIVEN a tool result carrying one readable and one corrupt image
197
+ const message = userMessage([
198
+ {
199
+ type: "tool_result",
200
+ tool_use_id: "tool-1",
201
+ content: "two screenshots",
202
+ contentBlocks: [
203
+ inlineImage("image/png", PNG_B64),
204
+ inlineImage("image/png", CORRUPT_PNG_B64),
205
+ ],
206
+ },
207
+ ]);
208
+
209
+ // WHEN the send boundary resolves it
210
+ const [resolved] = await resolveMediaReferences([message]);
211
+
212
+ // THEN the tool_use/tool_result pairing survives with the bad image noted
213
+ const toolResult = resolved.content[0];
214
+ if (toolResult.type !== "tool_result") {
215
+ throw new Error(`expected tool_result, got ${toolResult.type}`);
216
+ }
217
+ expect(toolResult.contentBlocks).toEqual([
218
+ inlineImage("image/png", PNG_B64),
219
+ { type: "text", text: UNSENDABLE_IMAGE_FORMAT_NOTE },
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("isUnsendableImageSource", () => {
225
+ beforeEach(resetTables);
226
+
227
+ test("reports a stored attachment the send boundary will drop", async () => {
228
+ // GIVEN one attachment holding a real PNG and one holding HEIC bytes
229
+ const good = await storedImageRef("good.png", "image/png", PNG_B64);
230
+ const bad = await storedImageRef("bad.png", "image/png", HEIC_B64);
231
+ if (good.type !== "image" || bad.type !== "image") {
232
+ throw new Error("expected image blocks");
233
+ }
234
+
235
+ // WHEN each source is checked
236
+ // THEN only the unreadable one is reported, matching what the boundary does
237
+ expect(isUnsendableImageSource(good.source)).toBe(false);
238
+ expect(isUnsendableImageSource(bad.source)).toBe(true);
239
+ });
240
+
241
+ test("does not report a reference whose attachment is gone", () => {
242
+ // GIVEN a reference with no row behind it
243
+ // WHEN it is checked
244
+ // THEN it is not called unsendable: a missing payload is a separate
245
+ // failure, and naming it in the drop card would misinform the user
246
+ expect(
247
+ isUnsendableImageSource({
248
+ type: "workspace_ref",
249
+ media_type: "image/png",
250
+ attachmentId: "missing-attachment",
251
+ sizeBytes: 10,
252
+ filename: "gone.png",
253
+ }),
254
+ ).toBe(false);
255
+ });
256
+ });
257
+
258
+ describe("unsendable-image notice", () => {
259
+ beforeEach(resetTables);
260
+
261
+ test("names only the attachments the model cannot see", async () => {
262
+ // GIVEN a message whose attachments are one valid PNG and one HEIC-as-PNG
263
+ const msgConv = createConversation();
264
+ await addMessage(msgConv.id, "user", "look at these");
265
+ const good = await storedImageRef("holiday.png", "image/png", PNG_B64);
266
+ const bad = await storedImageRef("beach.png", "image/png", HEIC_B64);
267
+ if (good.type !== "image" || bad.type !== "image") {
268
+ throw new Error("expected image blocks");
269
+ }
270
+
271
+ // WHEN the notice decides what to name
272
+ const filenames = unsendableImageFilenames([
273
+ { filename: "holiday.png", source: good.source },
274
+ { filename: "beach.png", source: bad.source },
275
+ ]);
276
+
277
+ // THEN only the dropped file is named, so the user knows which to convert
278
+ expect(filenames).toEqual(["beach.png"]);
279
+
280
+ // AND the card copy names it and says what to do about it
281
+ expect(unsendableImageCardText(filenames)).toBe(
282
+ "beach.png was not sent to the model: the file is not a PNG, JPEG, GIF, or WebP image. Convert it and attach it again to include it.",
283
+ );
284
+ });
285
+
286
+ test("posts no notice when every attachment is readable", async () => {
287
+ // GIVEN attachments that are all valid images
288
+ const good = await storedImageRef("one.png", "image/png", PNG_B64);
289
+ if (good.type !== "image") {
290
+ throw new Error("expected an image block");
291
+ }
292
+
293
+ // WHEN the notice decides what to name
294
+ // THEN there is nothing to say, so no card is written
295
+ expect(
296
+ unsendableImageFilenames([{ filename: "one.png", source: good.source }]),
297
+ ).toEqual([]);
298
+ });
299
+
300
+ test("names every dropped file when a batch has several", () => {
301
+ // GIVEN two inline attachments whose bytes are unreadable
302
+ // WHEN the card copy is built for them
303
+ const text = unsendableImageCardText(["a.png", "b.png"]);
304
+
305
+ // THEN both are named in plural copy
306
+ expect(text).toBe(
307
+ "a.png, b.png were not sent to the model: the files are not PNG, JPEG, GIF, or WebP images. Convert them and attach them again to include them.",
308
+ );
309
+ });
310
+ });
@@ -1,7 +1,7 @@
1
1
  import { existsSync, rmSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { afterEach, describe, expect, test } from "bun:test";
4
+ import { afterAll, afterEach, describe, expect, test } from "bun:test";
5
5
 
6
6
  import {
7
7
  ensureDataDir,
@@ -23,6 +23,21 @@ import {
23
23
  } from "../util/platform.js";
24
24
 
25
25
  const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
26
+
27
+ // This file characterizes path resolution itself (including the ~/.vellum
28
+ // fallback and literal override paths) without reading or writing those
29
+ // locations, so the live-workspace guard is deliberately bypassed.
30
+ const originalAllowRealWorkspace =
31
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
32
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS = "1";
33
+ afterAll(() => {
34
+ if (originalAllowRealWorkspace === undefined) {
35
+ delete process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS;
36
+ } else {
37
+ process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS =
38
+ originalAllowRealWorkspace;
39
+ }
40
+ });
26
41
  const originalVellumEnvironment = process.env.VELLUM_ENVIRONMENT;
27
42
  const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
28
43
 
@@ -136,9 +136,14 @@ describe("ensurePluginApiShim", () => {
136
136
  `}));\n`,
137
137
  );
138
138
 
139
+ // Explicit env spread: under bun test, a child's default env reflects the
140
+ // process-start environ, not later process.env mutations. Without this
141
+ // the child misses the preload's VELLUM_WORKSPACE_DIR and resolves the
142
+ // real ~/.vellum/workspace (refused by the live-workspace guard).
139
143
  const result = spawnSync(process.execPath, ["run", probe], {
140
144
  encoding: "utf8",
141
145
  timeout: 30_000,
146
+ env: { ...process.env },
142
147
  });
143
148
  expect(result.status).toBe(0);
144
149
  const lastLine = result.stdout.trim().split("\n").at(-1) ?? "";
@@ -98,6 +98,7 @@ mock.module("../../../ipc/gateway-client.js", () => ({
98
98
  }));
99
99
 
100
100
  mock.module("../../../messaging/providers/slack/send.js", () => ({
101
+ updateSlackMessage: async () => ({ ok: true, ts: "ts" }),
101
102
  sendSlackReply: async (
102
103
  chatId: string,
103
104
  text: string,