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