@vellumai/assistant 0.11.4-dev.202608202309.2fd95bc → 0.11.4-dev.202608202341.f06cdd5
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/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +0 -2
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +10 -3
- package/src/__tests__/channel-reply-delivery.test.ts +15 -1
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/file-write-tool.test.ts +4 -2
- package/src/__tests__/host-shell-tool.test.ts +23 -4
- package/src/__tests__/live-workspace-guard.test.ts +75 -0
- package/src/__tests__/mcp-abort-signal.test.ts +1 -1
- package/src/__tests__/mcp-client-auth.test.ts +1 -1
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +1 -1
- package/src/__tests__/media-resolve-image-validation.test.ts +310 -0
- package/src/__tests__/platform.test.ts +16 -1
- package/src/__tests__/plugin-api-shim.test.ts +5 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
- package/src/cli/lib/__tests__/toggle-plugin.test.ts +2 -1
- package/src/daemon/conversation-error.ts +13 -0
- package/src/daemon/conversation-messaging.ts +22 -2
- package/src/daemon/trust-context.ts +3 -3
- package/src/daemon/unsendable-image-notice.ts +76 -0
- package/src/ipc/__tests__/socket-path.test.ts +15 -7
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +1 -1
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +31 -0
- package/src/messaging/providers/channel-transport.ts +33 -0
- package/src/messaging/providers/index.ts +21 -3
- package/src/messaging/providers/slack/send.test.ts +6 -9
- package/src/messaging/providers/slack/send.ts +32 -20
- package/src/messaging/providers/slack/transport.ts +11 -1
- package/src/notifications/adapters/slack.ts +10 -5
- package/src/persistence/conversation-crud.ts +9 -18
- package/src/persistence/conversation-types.ts +22 -0
- package/src/plugin-api/__tests__/import-graph-partial-mock.test.ts +67 -0
- package/src/plugin-api/index.ts +6 -0
- package/src/plugin-api/system-card.ts +36 -0
- package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +361 -110
- package/src/plugins/defaults/image-fallback/__tests__/vision-recovery.test.ts +4 -0
- package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +58 -0
- package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +64 -23
- package/src/plugins/defaults/image-recovery/detect.ts +24 -1
- package/src/providers/gemini/client.ts +5 -2
- package/src/providers/media-resolve.ts +176 -15
- package/src/runtime/channel-reply-delivery.ts +42 -11
- package/src/runtime/routes/__tests__/system-card-turn-termination.test.ts +107 -0
- package/src/runtime/routes/canned-message-complete.ts +68 -29
- package/src/runtime/routes/guardian-approval-interception.ts +5 -6
- package/src/runtime/routes/inbound-message-handler.ts +7 -5
- package/src/util/image-conversion.ts +40 -9
- 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
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
880
|
-
process.env.VELLUM_DATA_DIR = "
|
|
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:
|
|
900
|
-
VELLUM_DATA_DIR: "
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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,
|