@vellumai/assistant 0.10.4-dev.202607020106.aa66e63 → 0.10.4-dev.202607020525.d9ced46
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/package.json +1 -1
- package/src/__tests__/attachments-store-heic-normalize.test.ts +230 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -0
- package/src/__tests__/clear-all-lexical.test.ts +7 -10
- package/src/__tests__/context-search-conversations-source.test.ts +3 -44
- package/src/__tests__/heic-fixture.ts +55 -0
- package/src/__tests__/image-conversion.test.ts +170 -0
- package/src/__tests__/job-handler-registry-guard.test.ts +6 -5
- package/src/__tests__/lexical-index-dual-write.test.ts +19 -11
- package/src/__tests__/list-messages-attachments.test.ts +92 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -2
- package/src/agent/image-optimize.ts +17 -127
- package/src/daemon/conversation-agent-loop-handlers.ts +122 -43
- package/src/daemon/conversation-history.ts +25 -9
- package/src/daemon/conversation-messaging.ts +1 -1
- package/src/daemon/conversation-turn-finalize.ts +1 -1
- package/src/daemon/lifecycle.ts +12 -1
- package/src/jobs/register-job-handlers.ts +24 -1
- package/src/persistence/__tests__/conversation-queries-search.test.ts +14 -15
- package/src/persistence/__tests__/jobs-worker-memory-disabled.test.ts +95 -0
- package/src/persistence/attachments-store.ts +70 -8
- package/src/persistence/conversation-crud.ts +7 -0
- package/src/persistence/conversation-queries.ts +31 -21
- package/src/{plugins/defaults/memory/job-handlers/__tests__/backfill-lexical-index.test.ts → persistence/job-handlers/__tests__/message-lexical-backfill.test.ts} +50 -67
- package/src/persistence/job-handlers/__tests__/message-lexical-enqueue.test.ts +164 -0
- package/src/{plugins/defaults/memory/job-handlers/__tests__/index-message-lexical.test.ts → persistence/job-handlers/__tests__/message-lexical.test.ts} +29 -35
- package/src/{plugins/defaults/memory/job-handlers/backfill-lexical-index.ts → persistence/job-handlers/message-lexical-backfill.ts} +23 -28
- package/src/{plugins/defaults/memory/job-handlers/index-message-lexical.ts → persistence/job-handlers/message-lexical.ts} +56 -103
- package/src/persistence/jobs-store.ts +42 -6
- package/src/persistence/jobs-worker.ts +54 -20
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +22 -15
- package/src/plugins/defaults/memory/job-handlers.ts +3 -23
- package/src/plugins/defaults/memory/persistence-hooks.ts +6 -21
- package/src/plugins/defaults/memory/startup.ts +0 -9
- package/src/runtime/routes/attachment-routes.ts +54 -3
- package/src/runtime/routes/conversation-routes.ts +10 -2
- package/src/runtime/routes/conversations-import-routes.ts +4 -2
- package/src/runtime/routes/inbound-message-handler.ts +1 -0
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +4 -2
- package/src/runtime/routes/index.ts +1 -1
- package/src/{plugins/defaults/memory → runtime}/routes/messages-lexical-routes.ts +11 -14
- package/src/util/image-conversion.ts +268 -0
- package/src/plugins/defaults/memory/job-handlers/__tests__/lexical-cleanup-disabled.test.ts +0 -197
package/package.json
CHANGED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring tests for HEIF/HEIC storage normalization: user-sourced ingress
|
|
3
|
+
* paths store a JPEG master (rewritten filename, mime, size, bytes) while
|
|
4
|
+
* register and assistant-outbound paths keep content verbatim.
|
|
5
|
+
*
|
|
6
|
+
* The converter module is mocked (keyed on the declared mime type) so these
|
|
7
|
+
* tests run identically with and without macOS sips; real conversion is
|
|
8
|
+
* covered in image-conversion.test.ts. mock.module is process-global — keep
|
|
9
|
+
* these cases in this file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeAll, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
mock.module("../util/logger.js", () => ({
|
|
15
|
+
getLogger: () =>
|
|
16
|
+
new Proxy({} as Record<string, unknown>, {
|
|
17
|
+
get: () => () => {},
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module("../config/loader.js", () => ({
|
|
22
|
+
loadConfig: () => ({}),
|
|
23
|
+
getConfig: () => ({}),
|
|
24
|
+
invalidateConfigCache: () => {},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
mock.module("../config/env.js", () => ({
|
|
28
|
+
isHttpAuthDisabled: () => true,
|
|
29
|
+
getAssistantDomain: () => "vellum.me",
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const FAKE_JPEG = Buffer.from("fake-jpeg-master-bytes");
|
|
33
|
+
const FAKE_JPEG_B64 = FAKE_JPEG.toString("base64");
|
|
34
|
+
|
|
35
|
+
mock.module("../util/image-conversion.js", () => ({
|
|
36
|
+
convertImageToJpeg: () => FAKE_JPEG,
|
|
37
|
+
isHeifImage: (bytes: Uint8Array) => bytes.length >= 12,
|
|
38
|
+
jpegFilenameFor: (filename: string) =>
|
|
39
|
+
`${filename.replace(/\.[^./\\]+$/, "")}.jpg`,
|
|
40
|
+
normalizeImageBytes: (mimeType: string, bytes: Uint8Array) =>
|
|
41
|
+
mimeType === "image/heic"
|
|
42
|
+
? { mimeType: "image/jpeg", bytes: FAKE_JPEG, converted: true }
|
|
43
|
+
: { mimeType, bytes, converted: false },
|
|
44
|
+
normalizeImageBase64: (mimeType: string, dataBase64: string) =>
|
|
45
|
+
mimeType === "image/heic"
|
|
46
|
+
? { mimeType: "image/jpeg", dataBase64: FAKE_JPEG_B64, converted: true }
|
|
47
|
+
: { mimeType, dataBase64, converted: false },
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
existsSync,
|
|
52
|
+
mkdirSync,
|
|
53
|
+
mkdtempSync,
|
|
54
|
+
readFileSync,
|
|
55
|
+
writeFileSync,
|
|
56
|
+
} from "node:fs";
|
|
57
|
+
import { tmpdir } from "node:os";
|
|
58
|
+
import { join } from "node:path";
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
attachInlineAttachmentToMessage,
|
|
62
|
+
getAttachmentById,
|
|
63
|
+
getFilePathForAttachment,
|
|
64
|
+
uploadAttachment,
|
|
65
|
+
uploadAttachmentFromBytes,
|
|
66
|
+
} from "../persistence/attachments-store.js";
|
|
67
|
+
import {
|
|
68
|
+
addMessage,
|
|
69
|
+
createConversation,
|
|
70
|
+
} from "../persistence/conversation-crud.js";
|
|
71
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
72
|
+
import {
|
|
73
|
+
attachmentMetadataSchema,
|
|
74
|
+
ROUTES,
|
|
75
|
+
} from "../runtime/routes/attachment-routes.js";
|
|
76
|
+
import type { RouteHandlerArgs } from "../runtime/routes/types.js";
|
|
77
|
+
import { getWorkspaceDir } from "../util/platform.js";
|
|
78
|
+
import { fakeHeifHeaderBytes } from "./heic-fixture.js";
|
|
79
|
+
|
|
80
|
+
const uploadRoute = ROUTES.find((r) => r.operationId === "attachment_upload")!;
|
|
81
|
+
const registerRoute = ROUTES.find(
|
|
82
|
+
(r) => r.operationId === "attachment_register",
|
|
83
|
+
)!;
|
|
84
|
+
|
|
85
|
+
const HEIC_BYTES = fakeHeifHeaderBytes();
|
|
86
|
+
const HEIC_B64 = HEIC_BYTES.toString("base64");
|
|
87
|
+
|
|
88
|
+
function jsonUploadArgs(body: Record<string, unknown>): RouteHandlerArgs {
|
|
89
|
+
return {
|
|
90
|
+
body,
|
|
91
|
+
rawBody: new TextEncoder().encode(JSON.stringify(body)),
|
|
92
|
+
headers: { "content-type": "application/json" },
|
|
93
|
+
queryParams: {},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("HEIC upload normalization wiring", () => {
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
await initializeDb();
|
|
100
|
+
}, 30_000);
|
|
101
|
+
|
|
102
|
+
test("uploadAttachmentFromBytes stores a JPEG master for HEIC", () => {
|
|
103
|
+
const stored = uploadAttachmentFromBytes(
|
|
104
|
+
"IMG_5487.HEIC",
|
|
105
|
+
"image/heic",
|
|
106
|
+
HEIC_BYTES,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(stored.originalFilename).toBe("IMG_5487.jpg");
|
|
110
|
+
expect(stored.mimeType).toBe("image/jpeg");
|
|
111
|
+
expect(stored.sizeBytes).toBe(FAKE_JPEG.length);
|
|
112
|
+
const stagedPath = getFilePathForAttachment(stored.id);
|
|
113
|
+
expect(stagedPath).not.toBeNull();
|
|
114
|
+
expect(readFileSync(stagedPath!).equals(FAKE_JPEG)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("uploadAttachmentFromBytes leaves non-HEIC uploads verbatim", () => {
|
|
118
|
+
const stored = uploadAttachmentFromBytes(
|
|
119
|
+
"photo.png",
|
|
120
|
+
"image/png",
|
|
121
|
+
HEIC_BYTES,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(stored.originalFilename).toBe("photo.png");
|
|
125
|
+
expect(stored.mimeType).toBe("image/png");
|
|
126
|
+
expect(stored.sizeBytes).toBe(HEIC_BYTES.length);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("uploadAttachment (base64) stores a JPEG master for HEIC", () => {
|
|
130
|
+
const stored = uploadAttachment("IMG_1.heic", "image/heic", HEIC_B64);
|
|
131
|
+
|
|
132
|
+
expect(stored.originalFilename).toBe("IMG_1.jpg");
|
|
133
|
+
expect(stored.mimeType).toBe("image/jpeg");
|
|
134
|
+
expect(stored.sizeBytes).toBe(FAKE_JPEG.length);
|
|
135
|
+
const row = getAttachmentById(stored.id);
|
|
136
|
+
expect(row?.dataBase64).toBe(FAKE_JPEG_B64);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("uploadAttachment (base64) leaves non-HEIC uploads verbatim", () => {
|
|
140
|
+
const stored = uploadAttachment("photo.png", "image/png", HEIC_B64);
|
|
141
|
+
|
|
142
|
+
expect(stored.originalFilename).toBe("photo.png");
|
|
143
|
+
expect(stored.mimeType).toBe("image/png");
|
|
144
|
+
expect(stored.sizeBytes).toBe(HEIC_BYTES.length);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("attachInlineAttachmentToMessage normalizes only when opted in", async () => {
|
|
148
|
+
const conv = createConversation();
|
|
149
|
+
const msg = await addMessage(
|
|
150
|
+
conv.id,
|
|
151
|
+
"user",
|
|
152
|
+
JSON.stringify([{ type: "text", text: "photos" }]),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const normalized = attachInlineAttachmentToMessage(
|
|
156
|
+
msg.id,
|
|
157
|
+
0,
|
|
158
|
+
"IMG_2.HEIC",
|
|
159
|
+
"image/heic",
|
|
160
|
+
HEIC_B64,
|
|
161
|
+
{ normalizeImage: true },
|
|
162
|
+
);
|
|
163
|
+
expect(normalized.originalFilename).toBe("IMG_2.jpg");
|
|
164
|
+
expect(normalized.mimeType).toBe("image/jpeg");
|
|
165
|
+
|
|
166
|
+
// Assistant-outbound attachments are stored verbatim: no normalizeImage
|
|
167
|
+
// flag, no rewrite, even for HEIC content.
|
|
168
|
+
const verbatim = attachInlineAttachmentToMessage(
|
|
169
|
+
msg.id,
|
|
170
|
+
1,
|
|
171
|
+
"IMG_3.HEIC",
|
|
172
|
+
"image/heic",
|
|
173
|
+
HEIC_B64,
|
|
174
|
+
);
|
|
175
|
+
expect(verbatim.originalFilename).toBe("IMG_3.HEIC");
|
|
176
|
+
expect(verbatim.mimeType).toBe("image/heic");
|
|
177
|
+
expect(verbatim.sizeBytes).toBe(HEIC_BYTES.length);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("JSON file-path upload converts the daemon-owned copy", async () => {
|
|
181
|
+
const sourceDir = mkdtempSync(join(tmpdir(), "vellum-heic-src-"));
|
|
182
|
+
const sourcePath = join(sourceDir, "IMG_4.HEIC");
|
|
183
|
+
writeFileSync(sourcePath, HEIC_BYTES);
|
|
184
|
+
|
|
185
|
+
const raw = await uploadRoute.handler(
|
|
186
|
+
jsonUploadArgs({
|
|
187
|
+
filename: "IMG_4.HEIC",
|
|
188
|
+
mimeType: "image/heic",
|
|
189
|
+
filePath: sourcePath,
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
const result = attachmentMetadataSchema.parse(raw);
|
|
193
|
+
|
|
194
|
+
expect(result.filename).toBe("IMG_4.jpg");
|
|
195
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
196
|
+
expect(result.sizeBytes).toBe(FAKE_JPEG.length);
|
|
197
|
+
// The caller's source file is never mutated.
|
|
198
|
+
expect(readFileSync(sourcePath).equals(HEIC_BYTES)).toBe(true);
|
|
199
|
+
// The staged raw copy is replaced by the converted one.
|
|
200
|
+
const stagedPath = getFilePathForAttachment(result.id);
|
|
201
|
+
expect(stagedPath).not.toBeNull();
|
|
202
|
+
expect(stagedPath!.endsWith(".jpg")).toBe(true);
|
|
203
|
+
expect(readFileSync(stagedPath!).equals(FAKE_JPEG)).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("attachment register keeps workspace files verbatim", async () => {
|
|
207
|
+
const registerDir = join(getWorkspaceDir(), "register-fixtures");
|
|
208
|
+
mkdirSync(registerDir, { recursive: true });
|
|
209
|
+
const registeredPath = join(registerDir, "IMG_5.HEIC");
|
|
210
|
+
writeFileSync(registeredPath, HEIC_BYTES);
|
|
211
|
+
|
|
212
|
+
const raw = await registerRoute.handler(
|
|
213
|
+
jsonUploadArgs({
|
|
214
|
+
path: registeredPath,
|
|
215
|
+
mimeType: "image/heic",
|
|
216
|
+
filename: "IMG_5.HEIC",
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
const result = raw as {
|
|
220
|
+
originalFilename: string;
|
|
221
|
+
mimeType: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
expect(result.originalFilename).toBe("IMG_5.HEIC");
|
|
225
|
+
expect(result.mimeType).toBe("image/heic");
|
|
226
|
+
// Registered files are referenced in place, never rewritten.
|
|
227
|
+
expect(existsSync(registeredPath)).toBe(true);
|
|
228
|
+
expect(readFileSync(registeredPath).equals(HEIC_BYTES)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -195,6 +195,7 @@ mock.module("../persistence/jobs-store.js", () => ({
|
|
|
195
195
|
automatic: "automatic",
|
|
196
196
|
manual: "manual",
|
|
197
197
|
},
|
|
198
|
+
MESSAGE_LEXICAL_JOB_TYPES: [],
|
|
198
199
|
resetRunningJobsToPending: mock(() => 0),
|
|
199
200
|
SLOW_LLM_JOB_TYPES: [],
|
|
200
201
|
upsertAutoAnalysisJob: mock(() => "job-auto-analysis"),
|
|
@@ -24,17 +24,14 @@ mock.module("../util/logger.js", () => ({
|
|
|
24
24
|
// (`@qdrant/js-client-rest`, `uuid`, the local TF-IDF encoder) resolve in the
|
|
25
25
|
// worktree.
|
|
26
26
|
const actualLexical =
|
|
27
|
-
await import("../
|
|
27
|
+
await import("../persistence/job-handlers/message-lexical.js");
|
|
28
28
|
let clearCalls = 0;
|
|
29
|
-
mock.module(
|
|
30
|
-
|
|
31
|
-
() =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
}),
|
|
37
|
-
);
|
|
29
|
+
mock.module("../persistence/job-handlers/message-lexical.js", () => ({
|
|
30
|
+
...actualLexical,
|
|
31
|
+
clearMessagesLexicalIndex: async () => {
|
|
32
|
+
clearCalls += 1;
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
38
35
|
|
|
39
36
|
import { clearAll } from "../persistence/conversation-crud.js";
|
|
40
37
|
import { initializeDb } from "../persistence/db-init.js";
|
|
@@ -35,22 +35,6 @@ mock.module("../persistence/conversation-search-lexical.js", () => ({
|
|
|
35
35
|
},
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
|
-
// Drives the real recall availability gate: when true the source yields no
|
|
39
|
-
// evidence, because the lexical index write path is suppressed and the
|
|
40
|
-
// collection is never populated. Defaults false so every other test exercises
|
|
41
|
-
// the live path. Spread the real module so its other exports (job handlers,
|
|
42
|
-
// enqueue helpers) stay intact for transitive importers.
|
|
43
|
-
let suppressIndexing = false;
|
|
44
|
-
const realLexicalModule =
|
|
45
|
-
await import("../plugins/defaults/memory/job-handlers/index-message-lexical.js");
|
|
46
|
-
mock.module(
|
|
47
|
-
"../plugins/defaults/memory/job-handlers/index-message-lexical.js",
|
|
48
|
-
() => ({
|
|
49
|
-
...realLexicalModule,
|
|
50
|
-
isMemoryIndexingSuppressed: () => suppressIndexing,
|
|
51
|
-
}),
|
|
52
|
-
);
|
|
53
|
-
|
|
54
38
|
import {
|
|
55
39
|
deleteMemoryCheckpoint,
|
|
56
40
|
LEXICAL_BACKFILL_COMPLETE_KEY,
|
|
@@ -70,15 +54,13 @@ describe("searchConversationSource (qdrant lexical index)", () => {
|
|
|
70
54
|
getDb().run("DELETE FROM messages");
|
|
71
55
|
getDb().run("DELETE FROM conversations");
|
|
72
56
|
lexicalCalls = [];
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// the backfill complete; the gates are covered by their own tests.
|
|
57
|
+
// Content evidence is available once the index is populated (backfill
|
|
58
|
+
// complete). Most tests exercise that path, so mark the backfill
|
|
59
|
+
// complete; the gate is covered by its own test.
|
|
77
60
|
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
78
61
|
});
|
|
79
62
|
|
|
80
63
|
afterEach(() => {
|
|
81
|
-
suppressIndexing = false;
|
|
82
64
|
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
83
65
|
lexicalMockImpl = () => {
|
|
84
66
|
throw new Error(
|
|
@@ -143,29 +125,6 @@ describe("searchConversationSource (qdrant lexical index)", () => {
|
|
|
143
125
|
expect(unicodeResult.evidence).toEqual([]);
|
|
144
126
|
});
|
|
145
127
|
|
|
146
|
-
test("returns no evidence when memory indexing is suppressed", async () => {
|
|
147
|
-
await seedConversation({
|
|
148
|
-
title: "Suppressed indexing notes",
|
|
149
|
-
content: "The suppressedtoken decision is recorded here.",
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// The lexical index is never populated while indexing is suppressed, so
|
|
153
|
-
// the source must yield no evidence without consulting Qdrant.
|
|
154
|
-
suppressIndexing = true;
|
|
155
|
-
lexicalMockImpl = async () => {
|
|
156
|
-
throw new Error("searchMessageIdsLexical must not run while suppressed");
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const result = await searchConversationSource(
|
|
160
|
-
"suppressedtoken",
|
|
161
|
-
makeContext(),
|
|
162
|
-
5,
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
expect(lexicalCalls).toHaveLength(0);
|
|
166
|
-
expect(result.evidence).toEqual([]);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
128
|
test("returns no evidence until the backfill completion checkpoint is set", async () => {
|
|
170
129
|
await seedConversation({
|
|
171
130
|
title: "Pre-backfill notes",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HEIC fixture helpers for image-conversion tests.
|
|
3
|
+
*
|
|
4
|
+
* Real HEIC bytes can only be produced where `sips` exists (macOS), so
|
|
5
|
+
* consumers gate those tests with `describe.skipIf(process.platform !==
|
|
6
|
+
* "darwin")`. The fake-header helper works everywhere and exercises the
|
|
7
|
+
* conversion-failure fallback: the ftyp sniff accepts it, sips rejects it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const PNG_1PX_BASE64 =
|
|
16
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create real HEIC bytes via macOS `sips` (1×1 PNG upscaled to 64×64, then
|
|
20
|
+
* HEIC-encoded). Returns null when sips is unavailable or fails.
|
|
21
|
+
*/
|
|
22
|
+
export function makeHeicFixtureBytes(): Buffer | null {
|
|
23
|
+
try {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-heic-fixture-"));
|
|
25
|
+
const pngPath = join(dir, "src.png");
|
|
26
|
+
const bigPngPath = join(dir, "big.png");
|
|
27
|
+
const heicPath = join(dir, "out.heic");
|
|
28
|
+
writeFileSync(pngPath, Buffer.from(PNG_1PX_BASE64, "base64"));
|
|
29
|
+
execFileSync("sips", ["-z", "64", "64", pngPath, "--out", bigPngPath], {
|
|
30
|
+
stdio: "pipe",
|
|
31
|
+
});
|
|
32
|
+
execFileSync(
|
|
33
|
+
"sips",
|
|
34
|
+
["-s", "format", "heic", bigPngPath, "--out", heicPath],
|
|
35
|
+
{ stdio: "pipe" },
|
|
36
|
+
);
|
|
37
|
+
return readFileSync(heicPath) as Buffer;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A syntactically valid HEIF ftyp header followed by nothing decodable.
|
|
45
|
+
* Sniffs as HEIF but fails conversion on every platform.
|
|
46
|
+
*/
|
|
47
|
+
export function fakeHeifHeaderBytes(brand = "heic"): Buffer {
|
|
48
|
+
const buf = Buffer.alloc(24);
|
|
49
|
+
buf.writeUInt32BE(24, 0);
|
|
50
|
+
buf.write("ftyp", 4, "ascii");
|
|
51
|
+
buf.write(brand, 8, "ascii");
|
|
52
|
+
return buf;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const PNG_1PX_BYTES = Buffer.from(PNG_1PX_BASE64, "base64");
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared sips-backed image converter.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic (ftyp sniffing, filename rewriting, passthrough behavior) runs
|
|
5
|
+
* everywhere; actual conversion requires macOS `sips`, so those cases are
|
|
6
|
+
* gated on darwin.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
convertImageToJpeg,
|
|
13
|
+
isHeifImage,
|
|
14
|
+
jpegFilenameFor,
|
|
15
|
+
normalizeImageBase64,
|
|
16
|
+
normalizeImageBytes,
|
|
17
|
+
} from "../util/image-conversion.js";
|
|
18
|
+
import {
|
|
19
|
+
fakeHeifHeaderBytes,
|
|
20
|
+
makeHeicFixtureBytes,
|
|
21
|
+
PNG_1PX_BYTES,
|
|
22
|
+
} from "./heic-fixture.js";
|
|
23
|
+
|
|
24
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
25
|
+
|
|
26
|
+
function startsWithJpegMagic(bytes: Uint8Array): boolean {
|
|
27
|
+
return Buffer.from(bytes.slice(0, 3)).equals(JPEG_MAGIC);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("isHeifImage", () => {
|
|
31
|
+
test("accepts HEIF ftyp brands", () => {
|
|
32
|
+
for (const brand of ["heic", "heix", "heif", "mif1", "msf1", "hevc"]) {
|
|
33
|
+
expect(isHeifImage(fakeHeifHeaderBytes(brand))).toBe(true);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects AVIF brands (Chromium decodes AVIF natively)", () => {
|
|
38
|
+
expect(isHeifImage(fakeHeifHeaderBytes("avif"))).toBe(false);
|
|
39
|
+
expect(isHeifImage(fakeHeifHeaderBytes("avis"))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects non-ISO-BMFF content", () => {
|
|
43
|
+
expect(isHeifImage(PNG_1PX_BYTES)).toBe(false);
|
|
44
|
+
expect(isHeifImage(Buffer.from("plain text content"))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("rejects buffers shorter than the sniff window", () => {
|
|
48
|
+
expect(isHeifImage(Buffer.alloc(0))).toBe(false);
|
|
49
|
+
expect(isHeifImage(Buffer.from("ftypheic"))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("jpegFilenameFor", () => {
|
|
54
|
+
test("rewrites the extension to .jpg", () => {
|
|
55
|
+
expect(jpegFilenameFor("IMG_5487.HEIC")).toBe("IMG_5487.jpg");
|
|
56
|
+
expect(jpegFilenameFor("photo.heif")).toBe("photo.jpg");
|
|
57
|
+
expect(jpegFilenameFor("archive.tar.gz")).toBe("archive.tar.jpg");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("keeps existing .jpg/.jpeg extensions", () => {
|
|
61
|
+
expect(jpegFilenameFor("photo.jpg")).toBe("photo.jpg");
|
|
62
|
+
expect(jpegFilenameFor("photo.JPEG")).toBe("photo.JPEG");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("handles missing or empty names", () => {
|
|
66
|
+
expect(jpegFilenameFor("photo")).toBe("photo.jpg");
|
|
67
|
+
expect(jpegFilenameFor("")).toBe("attachment.jpg");
|
|
68
|
+
expect(jpegFilenameFor(".heic")).toBe("attachment.jpg");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("normalizeImageBytes passthrough", () => {
|
|
73
|
+
test("non-HEIF bytes pass through untouched", () => {
|
|
74
|
+
const result = normalizeImageBytes("image/png", PNG_1PX_BYTES);
|
|
75
|
+
expect(result.converted).toBe(false);
|
|
76
|
+
expect(result.mimeType).toBe("image/png");
|
|
77
|
+
expect(result.bytes).toBe(PNG_1PX_BYTES);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("HEIF header with undecodable payload passes through", () => {
|
|
81
|
+
// sips fails (or is absent off-macOS) → the original bytes are kept.
|
|
82
|
+
const fake = fakeHeifHeaderBytes();
|
|
83
|
+
const result = normalizeImageBytes("image/heic", fake);
|
|
84
|
+
expect(result.converted).toBe(false);
|
|
85
|
+
expect(result.mimeType).toBe("image/heic");
|
|
86
|
+
expect(result.bytes).toBe(fake);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("normalizeImageBase64 passthrough", () => {
|
|
91
|
+
test("non-HEIF payloads skip conversion", () => {
|
|
92
|
+
const b64 = PNG_1PX_BYTES.toString("base64");
|
|
93
|
+
const result = normalizeImageBase64("image/png", b64);
|
|
94
|
+
expect(result.converted).toBe(false);
|
|
95
|
+
expect(result.dataBase64).toBe(b64);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe.skipIf(process.platform !== "darwin")(
|
|
100
|
+
"real HEIC conversion (sips)",
|
|
101
|
+
() => {
|
|
102
|
+
let heicBytes: Buffer;
|
|
103
|
+
|
|
104
|
+
beforeAll(() => {
|
|
105
|
+
const fixture = makeHeicFixtureBytes();
|
|
106
|
+
if (!fixture) {
|
|
107
|
+
throw new Error("sips failed to produce a HEIC fixture on darwin");
|
|
108
|
+
}
|
|
109
|
+
heicBytes = fixture;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("fixture sniffs as HEIF", () => {
|
|
113
|
+
expect(isHeifImage(heicBytes)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("convertImageToJpeg produces JPEG bytes", () => {
|
|
117
|
+
const converted = convertImageToJpeg(heicBytes);
|
|
118
|
+
expect(converted).not.toBeNull();
|
|
119
|
+
expect(startsWithJpegMagic(converted!)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("conversion options produce distinct outputs (cache key isolation)", () => {
|
|
123
|
+
// A hash-only cache key would make the second call return the first
|
|
124
|
+
// call's cached full-resolution output.
|
|
125
|
+
const fullRes = convertImageToJpeg(heicBytes, { quality: 90 });
|
|
126
|
+
const downscaled = convertImageToJpeg(heicBytes, {
|
|
127
|
+
maxDimensionPx: 16,
|
|
128
|
+
quality: 90,
|
|
129
|
+
});
|
|
130
|
+
expect(fullRes).not.toBeNull();
|
|
131
|
+
expect(downscaled).not.toBeNull();
|
|
132
|
+
expect(fullRes!.equals(downscaled!)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("repeated conversion is stable (cache round-trip)", () => {
|
|
136
|
+
const first = convertImageToJpeg(heicBytes, { quality: 90 });
|
|
137
|
+
const second = convertImageToJpeg(heicBytes, { quality: 90 });
|
|
138
|
+
expect(first).not.toBeNull();
|
|
139
|
+
expect(second).not.toBeNull();
|
|
140
|
+
expect(first!.equals(second!)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("normalizeImageBytes converts to a JPEG master", () => {
|
|
144
|
+
const result = normalizeImageBytes("image/heic", heicBytes);
|
|
145
|
+
expect(result.converted).toBe(true);
|
|
146
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
147
|
+
expect(startsWithJpegMagic(result.bytes)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("normalizeImageBytes converts even when the declared mime is wrong", () => {
|
|
151
|
+
// Chromium reports empty file.type for .heic; clients coerce it to
|
|
152
|
+
// application/octet-stream. Detection is content-based.
|
|
153
|
+
const result = normalizeImageBytes("application/octet-stream", heicBytes);
|
|
154
|
+
expect(result.converted).toBe(true);
|
|
155
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("normalizeImageBase64 converts and re-encodes", () => {
|
|
159
|
+
const result = normalizeImageBase64(
|
|
160
|
+
"image/heic",
|
|
161
|
+
heicBytes.toString("base64"),
|
|
162
|
+
);
|
|
163
|
+
expect(result.converted).toBe(true);
|
|
164
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
165
|
+
expect(
|
|
166
|
+
startsWithJpegMagic(Buffer.from(result.dataBase64, "base64")),
|
|
167
|
+
).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
);
|
|
@@ -43,21 +43,22 @@ const MEMORY_JOB_TYPES = [
|
|
|
43
43
|
"memory_v2_activation_recompute",
|
|
44
44
|
"memory_v3_maintain",
|
|
45
45
|
"memory_retrospective",
|
|
46
|
-
"index_message_lexical",
|
|
47
|
-
"purge_conversation_lexical",
|
|
48
|
-
"delete_message_lexical",
|
|
49
|
-
"backfill_lexical_index",
|
|
50
46
|
].sort();
|
|
51
47
|
|
|
52
48
|
/**
|
|
53
49
|
* Job types wired directly by `registerMemoryJobHandlers` for domains that are
|
|
54
|
-
* not plugins (persistence cleanup,
|
|
50
|
+
* not plugins (persistence cleanup, message-content lexical indexing,
|
|
51
|
+
* conversations, media, home, runtime).
|
|
55
52
|
*/
|
|
56
53
|
const NON_PLUGIN_JOB_TYPES = [
|
|
57
54
|
"prune_old_conversations",
|
|
58
55
|
"prune_old_llm_request_logs",
|
|
59
56
|
"prune_old_trace_events",
|
|
60
57
|
"prune_old_tool_invocations",
|
|
58
|
+
"index_message_lexical",
|
|
59
|
+
"purge_conversation_lexical",
|
|
60
|
+
"delete_message_lexical",
|
|
61
|
+
"backfill_lexical_index",
|
|
61
62
|
"build_conversation_summary",
|
|
62
63
|
"media_processing",
|
|
63
64
|
"conversation_analyze",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Integration coverage for the messages lexical-index
|
|
2
|
-
// - a real persist (`addMessage`
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Integration coverage for the messages lexical-index write wiring:
|
|
2
|
+
// - a real persist (`addMessage`) enqueues one `index_message_lexical` job
|
|
3
|
+
// for the message, regardless of whether memory is enabled or disabled
|
|
4
|
+
// (message-content search is host infrastructure);
|
|
5
5
|
// - forking a conversation copies message rows WITHOUT routing through
|
|
6
|
-
//
|
|
6
|
+
// the persist path, so a fork enqueues ZERO `index_message_lexical`
|
|
7
7
|
// jobs (the fork-exclusion regression);
|
|
8
8
|
// - wiping a conversation enqueues one `purge_conversation_lexical` job.
|
|
9
9
|
//
|
|
@@ -31,7 +31,9 @@ let throwFromIndex = false;
|
|
|
31
31
|
mock.module("../plugins/defaults/memory/indexer.js", () => ({
|
|
32
32
|
MIN_SEGMENT_CHARS: 50,
|
|
33
33
|
indexMessageNow: async () => {
|
|
34
|
-
if (throwFromIndex)
|
|
34
|
+
if (throwFromIndex) {
|
|
35
|
+
throw new Error("simulated segment-indexing failure");
|
|
36
|
+
}
|
|
35
37
|
return { indexedSegments: 0, enqueuedJobs: 0 };
|
|
36
38
|
},
|
|
37
39
|
enqueueBackfillJob: () => "",
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
} from "../persistence/conversation-crud.js";
|
|
59
61
|
import { getMemoryDb } from "../persistence/db-connection.js";
|
|
60
62
|
import { initializeDb } from "../persistence/db-init.js";
|
|
63
|
+
import { enqueueLexicalIndexForMessage } from "../persistence/job-handlers/message-lexical.js";
|
|
61
64
|
import type { MemoryJobType } from "../persistence/jobs-store.js";
|
|
62
65
|
import {
|
|
63
66
|
getMemoryPersistenceHooks,
|
|
@@ -66,7 +69,6 @@ import {
|
|
|
66
69
|
} from "../persistence/memory-lifecycle-hooks.js";
|
|
67
70
|
import { memoryJobs } from "../persistence/schema/index.js";
|
|
68
71
|
import { registerDefaultPluginPersistenceHooks } from "../plugins/defaults/index.js";
|
|
69
|
-
import { enqueueLexicalIndexForMessage } from "../plugins/defaults/memory/job-handlers/index-message-lexical.js";
|
|
70
72
|
|
|
71
73
|
await initializeDb();
|
|
72
74
|
|
|
@@ -76,7 +78,9 @@ function countJobs(type: MemoryJobType, conversationId?: string): number {
|
|
|
76
78
|
.from(memoryJobs)
|
|
77
79
|
.where(eq(memoryJobs.type, type))
|
|
78
80
|
.all();
|
|
79
|
-
if (conversationId == null)
|
|
81
|
+
if (conversationId == null) {
|
|
82
|
+
return rows.length;
|
|
83
|
+
}
|
|
80
84
|
return rows.filter((r) => {
|
|
81
85
|
try {
|
|
82
86
|
return (
|
|
@@ -157,12 +161,16 @@ describe("messages lexical-index dual-write", () => {
|
|
|
157
161
|
expect(lexicalJobMessageIds()).toContain(message.id);
|
|
158
162
|
});
|
|
159
163
|
|
|
160
|
-
test("addMessage enqueues
|
|
164
|
+
test("addMessage still enqueues an index_message_lexical job when memory is disabled", async () => {
|
|
161
165
|
setMemoryEnabled(false);
|
|
162
166
|
const conv = createConversation("Disabled memory thread");
|
|
163
|
-
await addMessage(
|
|
167
|
+
const message = await addMessage(
|
|
168
|
+
conv.id,
|
|
169
|
+
"user",
|
|
170
|
+
"lexically indexed regardless of memory state",
|
|
171
|
+
);
|
|
164
172
|
|
|
165
|
-
expect(
|
|
173
|
+
expect(lexicalJobMessageIds()).toContain(message.id);
|
|
166
174
|
});
|
|
167
175
|
|
|
168
176
|
test("enqueueLexicalIndexForMessage no-ops on an empty message id", () => {
|