@vellumai/assistant 0.11.8-dev.202609022016.7bd8f87 → 0.11.8-dev.202609022114.a0a4378
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-stored-path-annotation.test.ts +59 -0
- package/src/__tests__/attachments-store.test.ts +23 -0
- package/src/__tests__/attachments.test.ts +43 -0
- package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
- package/src/__tests__/process-message-display-content.test.ts +4 -9
- package/src/agent/attachments.ts +13 -2
- package/src/config/feature-flag-registry.json +1 -1
- package/src/daemon/conversation-messaging.ts +86 -25
- package/src/daemon/port-oversized-content.test.ts +89 -0
- package/src/daemon/port-oversized-content.ts +100 -0
- package/src/live-voice/__tests__/live-voice-photo.test.ts +11 -11
- package/src/providers/anthropic/client.ts +21 -24
- package/src/providers/content-block-size.test.ts +82 -0
- package/src/providers/content-block-size.ts +99 -0
- package/src/providers/file-block-text.test.ts +64 -0
- package/src/providers/file-block-text.ts +28 -0
- package/src/providers/gemini/client.ts +12 -7
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/openai/chat-completions-provider.ts +9 -25
- package/src/providers/openai/responses-provider.ts +11 -18
package/package.json
CHANGED
|
@@ -119,6 +119,65 @@ describe("persistQueuedMessageBody stored path annotations", () => {
|
|
|
119
119
|
expect(rebuiltBlock.text).toBe(annotation.text);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test("offloaded oversized text records its stored path for history reload", async () => {
|
|
123
|
+
const conv = createConversation();
|
|
124
|
+
const ctx = makeCtx(conv.id);
|
|
125
|
+
const original = "x".repeat(8_000_001);
|
|
126
|
+
|
|
127
|
+
await persistQueuedMessageBody(ctx, { content: original });
|
|
128
|
+
|
|
129
|
+
const row = rawGet<{ metadata: string; content: string }>(
|
|
130
|
+
"test:offloadMessage",
|
|
131
|
+
"SELECT metadata, content FROM messages ORDER BY created_at DESC LIMIT 1",
|
|
132
|
+
);
|
|
133
|
+
const meta = JSON.parse(row!.metadata) as {
|
|
134
|
+
attachmentStoredPaths?: Record<string, string>;
|
|
135
|
+
};
|
|
136
|
+
const storedPaths = Object.values(meta.attachmentStoredPaths ?? {});
|
|
137
|
+
expect(storedPaths).toHaveLength(1);
|
|
138
|
+
expect(storedPaths[0]!.endsWith("oversized-content.txt")).toBe(true);
|
|
139
|
+
expect(readFileSync(storedPaths[0]!).toString("utf8")).toBe(original);
|
|
140
|
+
|
|
141
|
+
const annotation = lastAnnotationBlock(ctx);
|
|
142
|
+
expect(annotation.text).toBe(
|
|
143
|
+
`[Attachment "oversized-content.txt" is stored at: ${storedPaths[0]}]`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const persisted = JSON.parse(row!.content) as ContentBlock[];
|
|
147
|
+
const rebuilt = reinjectAttachmentPathAnnotations(
|
|
148
|
+
persisted,
|
|
149
|
+
"user",
|
|
150
|
+
row!.metadata,
|
|
151
|
+
);
|
|
152
|
+
const rebuiltBlock = rebuilt.at(-1) as { type: "text"; text: string };
|
|
153
|
+
expect(rebuiltBlock.text).toBe(annotation.text);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("display-only offload is not named on the model-facing list", async () => {
|
|
157
|
+
const conv = createConversation();
|
|
158
|
+
const ctx = makeCtx(conv.id);
|
|
159
|
+
const display = "y".repeat(8_000_001);
|
|
160
|
+
|
|
161
|
+
await persistQueuedMessageBody(ctx, {
|
|
162
|
+
content: "fenced model copy",
|
|
163
|
+
displayContent: display,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const llmContent = ctx.messages[0].content as ContentBlock[];
|
|
167
|
+
expect(JSON.stringify(llmContent)).not.toContain("oversized-content.txt");
|
|
168
|
+
expect(JSON.stringify(llmContent)).toContain("fenced model copy");
|
|
169
|
+
expect(JSON.stringify(llmContent)).not.toContain(display);
|
|
170
|
+
|
|
171
|
+
const row = rawGet<{ metadata: string }>(
|
|
172
|
+
"test:displayOnlyOffloadMetadata",
|
|
173
|
+
"SELECT metadata FROM messages ORDER BY created_at DESC LIMIT 1",
|
|
174
|
+
);
|
|
175
|
+
const meta = JSON.parse(row!.metadata) as {
|
|
176
|
+
attachmentStoredPaths?: Record<string, string>;
|
|
177
|
+
};
|
|
178
|
+
expect(meta.attachmentStoredPaths).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
122
181
|
test("messages without attachments get no annotation block", async () => {
|
|
123
182
|
const conv = createConversation();
|
|
124
183
|
const ctx = makeCtx(conv.id);
|
|
@@ -2,6 +2,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
offloadOversizedText,
|
|
7
|
+
OVERSIZED_CONTENT_FILENAME,
|
|
8
|
+
} from "../daemon/port-oversized-content.js";
|
|
5
9
|
import {
|
|
6
10
|
attachInlineAttachmentToMessage,
|
|
7
11
|
AttachmentUploadError,
|
|
@@ -456,6 +460,25 @@ describe("createInlineAttachment (workspace_ref persistence)", () => {
|
|
|
456
460
|
stored.id,
|
|
457
461
|
]);
|
|
458
462
|
});
|
|
463
|
+
|
|
464
|
+
test("offloadOversizedText writes the original bytes next to the conversation", async () => {
|
|
465
|
+
const conv = createConversation();
|
|
466
|
+
const original = "x".repeat(32);
|
|
467
|
+
const result = await offloadOversizedText(
|
|
468
|
+
original,
|
|
469
|
+
{
|
|
470
|
+
conversationId: conv.id,
|
|
471
|
+
conversationCreatedAt: conv.createdAt,
|
|
472
|
+
},
|
|
473
|
+
16,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
expect(result.attachmentId).toBeDefined();
|
|
477
|
+
const filePath = getFilePathForAttachment(result.attachmentId!);
|
|
478
|
+
expect(filePath).toBeTruthy();
|
|
479
|
+
expect(filePath!.endsWith(OVERSIZED_CONTENT_FILENAME)).toBe(true);
|
|
480
|
+
expect(readFileSync(filePath!).toString("utf8")).toBe(original);
|
|
481
|
+
});
|
|
459
482
|
});
|
|
460
483
|
|
|
461
484
|
describe("attachInlineAttachmentToMessage filename collisions", () => {
|
|
@@ -127,6 +127,32 @@ describe("attachmentsToReferenceBlocks", () => {
|
|
|
127
127
|
expect(JSON.stringify(blocks)).not.toContain("data");
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("omits extracted_text on a video workspace_ref", () => {
|
|
131
|
+
const blocks = attachmentsToReferenceBlocks([
|
|
132
|
+
{
|
|
133
|
+
attachmentId: "att-vid",
|
|
134
|
+
filename: "clip.mp4",
|
|
135
|
+
mimeType: "video/mp4",
|
|
136
|
+
sizeBytes: 40_000_000,
|
|
137
|
+
extractedText: "this must not dump into the prompt",
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
expect(blocks).toEqual([
|
|
142
|
+
{
|
|
143
|
+
type: "file",
|
|
144
|
+
source: {
|
|
145
|
+
type: "workspace_ref",
|
|
146
|
+
media_type: "video/mp4",
|
|
147
|
+
attachmentId: "att-vid",
|
|
148
|
+
sizeBytes: 40_000_000,
|
|
149
|
+
filename: "clip.mp4",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
expect(JSON.stringify(blocks)).not.toContain("extracted");
|
|
154
|
+
});
|
|
155
|
+
|
|
130
156
|
test("builds a file workspace_ref with filename and extracted text", () => {
|
|
131
157
|
const blocks = attachmentsToReferenceBlocks([
|
|
132
158
|
{
|
|
@@ -208,6 +234,23 @@ describe("attachmentsToContentBlocks", () => {
|
|
|
208
234
|
expect(blocks[0].type).toBe("image");
|
|
209
235
|
});
|
|
210
236
|
|
|
237
|
+
test("omits extracted_text on an inline video file block", async () => {
|
|
238
|
+
const blocks = await attachmentsToContentBlocks([
|
|
239
|
+
{
|
|
240
|
+
filename: "clip.mp4",
|
|
241
|
+
mimeType: "video/mp4",
|
|
242
|
+
data: "AAAA",
|
|
243
|
+
extractedText: "must not dump",
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
expect(blocks).toHaveLength(1);
|
|
247
|
+
expect(blocks[0]).toMatchObject({
|
|
248
|
+
type: "file",
|
|
249
|
+
source: { type: "base64", media_type: "video/mp4", filename: "clip.mp4" },
|
|
250
|
+
});
|
|
251
|
+
expect(blocks[0]).not.toHaveProperty("extracted_text");
|
|
252
|
+
});
|
|
253
|
+
|
|
211
254
|
test("creates file content block for non-image mime types", async () => {
|
|
212
255
|
const blocks = await attachmentsToContentBlocks([
|
|
213
256
|
{ filename: "doc.pdf", mimeType: "application/pdf", data: "pdfdata" },
|
|
@@ -414,3 +414,80 @@ describe("unsendable-image notice", () => {
|
|
|
414
414
|
);
|
|
415
415
|
});
|
|
416
416
|
});
|
|
417
|
+
|
|
418
|
+
describe("resolveMediaReferences file workspace_ref stay-on-disk", () => {
|
|
419
|
+
beforeEach(resetTables);
|
|
420
|
+
|
|
421
|
+
test("does not inline over-cap text or extracted_text", async () => {
|
|
422
|
+
const conv = createConversation();
|
|
423
|
+
const stored = await createInlineAttachment(
|
|
424
|
+
conv.id,
|
|
425
|
+
conv.createdAt,
|
|
426
|
+
"oversized-content.txt",
|
|
427
|
+
"text/plain",
|
|
428
|
+
Buffer.from("hello-over-cap").toString("base64"),
|
|
429
|
+
);
|
|
430
|
+
const messages: Message[] = [
|
|
431
|
+
userMessage([
|
|
432
|
+
{
|
|
433
|
+
type: "file",
|
|
434
|
+
source: {
|
|
435
|
+
type: "workspace_ref",
|
|
436
|
+
media_type: "text/plain",
|
|
437
|
+
attachmentId: stored.id,
|
|
438
|
+
sizeBytes: 8_000_001,
|
|
439
|
+
filename: "oversized-content.txt",
|
|
440
|
+
},
|
|
441
|
+
extracted_text: "this dump must not be resent",
|
|
442
|
+
},
|
|
443
|
+
]),
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
const resolved = await resolveMediaReferences(messages);
|
|
447
|
+
const block = resolved[0]!.content[0]!;
|
|
448
|
+
expect(block.type).toBe("file");
|
|
449
|
+
if (block.type !== "file") {
|
|
450
|
+
throw new Error("expected a file block");
|
|
451
|
+
}
|
|
452
|
+
expect(block.source.type).toBe("workspace_ref");
|
|
453
|
+
expect(block.extracted_text).toBeUndefined();
|
|
454
|
+
expect(JSON.stringify(resolved)).not.toContain("this dump must not be resent");
|
|
455
|
+
expect(JSON.stringify(resolved)).not.toContain("hello-over-cap");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("does not inline video bytes or extracted_text", async () => {
|
|
459
|
+
const conv = createConversation();
|
|
460
|
+
const stored = await createInlineAttachment(
|
|
461
|
+
conv.id,
|
|
462
|
+
conv.createdAt,
|
|
463
|
+
"clip.mp4",
|
|
464
|
+
"video/mp4",
|
|
465
|
+
Buffer.from("not-a-real-video").toString("base64"),
|
|
466
|
+
);
|
|
467
|
+
const messages: Message[] = [
|
|
468
|
+
userMessage([
|
|
469
|
+
{
|
|
470
|
+
type: "file",
|
|
471
|
+
source: {
|
|
472
|
+
type: "workspace_ref",
|
|
473
|
+
media_type: "video/mp4",
|
|
474
|
+
attachmentId: stored.id,
|
|
475
|
+
sizeBytes: stored.sizeBytes,
|
|
476
|
+
filename: "clip.mp4",
|
|
477
|
+
},
|
|
478
|
+
extracted_text: "dump of the video as text",
|
|
479
|
+
},
|
|
480
|
+
]),
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
const resolved = await resolveMediaReferences(messages);
|
|
484
|
+
const block = resolved[0]!.content[0]!;
|
|
485
|
+
expect(block.type).toBe("file");
|
|
486
|
+
if (block.type !== "file") {
|
|
487
|
+
throw new Error("expected a file block");
|
|
488
|
+
}
|
|
489
|
+
expect(block.source.type).toBe("workspace_ref");
|
|
490
|
+
expect(block.extracted_text).toBeUndefined();
|
|
491
|
+
expect(JSON.stringify(resolved)).not.toContain("dump of the video");
|
|
492
|
+
});
|
|
493
|
+
});
|
|
@@ -414,20 +414,15 @@ describe("processMessage displayContent", () => {
|
|
|
414
414
|
type: "text",
|
|
415
415
|
text: modelContent,
|
|
416
416
|
});
|
|
417
|
-
|
|
418
|
-
string,
|
|
419
|
-
unknown
|
|
420
|
-
>;
|
|
421
|
-
expect(inMemoryFileBlock._attachmentId).toBe("att-1");
|
|
422
|
-
expect(inMemoryFileBlock).toMatchObject({
|
|
417
|
+
expect(inMemoryMessage.content[1]).toEqual({
|
|
423
418
|
type: "file",
|
|
424
419
|
source: {
|
|
425
|
-
type: "
|
|
420
|
+
type: "workspace_ref",
|
|
426
421
|
media_type: "application/pdf",
|
|
427
|
-
|
|
422
|
+
attachmentId: "att-stored",
|
|
423
|
+
sizeBytes: 9,
|
|
428
424
|
filename: "attachment.pdf",
|
|
429
425
|
},
|
|
430
|
-
extracted_text: undefined,
|
|
431
426
|
});
|
|
432
427
|
});
|
|
433
428
|
|
package/src/agent/attachments.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractedTextForFileBlock } from "../providers/content-block-size.js";
|
|
1
2
|
import {
|
|
2
3
|
attachmentIdFragment,
|
|
3
4
|
type ContentBlock,
|
|
@@ -64,6 +65,10 @@ export function attachmentsToReferenceBlocks(
|
|
|
64
65
|
} as ContentBlock;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
const extractedText = extractedTextForFileBlock(
|
|
69
|
+
ref.mimeType,
|
|
70
|
+
ref.extractedText,
|
|
71
|
+
);
|
|
67
72
|
return {
|
|
68
73
|
type: "file",
|
|
69
74
|
source: {
|
|
@@ -73,7 +78,7 @@ export function attachmentsToReferenceBlocks(
|
|
|
73
78
|
sizeBytes: ref.sizeBytes,
|
|
74
79
|
filename: ref.filename,
|
|
75
80
|
},
|
|
76
|
-
extracted_text:
|
|
81
|
+
...(extractedText !== undefined ? { extracted_text: extractedText } : {}),
|
|
77
82
|
} as ContentBlock;
|
|
78
83
|
});
|
|
79
84
|
}
|
|
@@ -99,6 +104,10 @@ export function attachmentsToContentBlocks(
|
|
|
99
104
|
} as ContentBlock;
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
const extractedText = extractedTextForFileBlock(
|
|
108
|
+
attachment.mimeType,
|
|
109
|
+
attachment.extractedText,
|
|
110
|
+
);
|
|
102
111
|
return {
|
|
103
112
|
type: "file",
|
|
104
113
|
source: {
|
|
@@ -107,7 +116,9 @@ export function attachmentsToContentBlocks(
|
|
|
107
116
|
data: attachment.data,
|
|
108
117
|
filename: attachment.filename,
|
|
109
118
|
},
|
|
110
|
-
|
|
119
|
+
...(extractedText !== undefined
|
|
120
|
+
? { extracted_text: extractedText }
|
|
121
|
+
: {}),
|
|
111
122
|
...attachmentIdFragment(attachment.id),
|
|
112
123
|
} as ContentBlock;
|
|
113
124
|
}),
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"scope": "client",
|
|
195
195
|
"key": "camera-gate-debug-hud",
|
|
196
196
|
"label": "Camera Gate Debug HUD",
|
|
197
|
-
"description": "Unlock the camera frame gate's tuning readout: the
|
|
197
|
+
"description": "Unlock the camera frame gate's tuning readout: the switch in the camera's view options that turns it on, and the live panel over the composer's sight tile and the voice room's viewfinder showing motion, novelty and detail against their thresholds, the check that decided each frame, and sliders that move those thresholds while the camera runs. Staff sessions get it without the flag; the flag is for local gateway sessions, which carry no platform identity and are where the gate is tuned.",
|
|
198
198
|
"defaultEnabled": false
|
|
199
199
|
},
|
|
200
200
|
{
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
type MessageAttachmentInput,
|
|
16
16
|
} from "../agent/attachments.js";
|
|
17
17
|
import { optimizeImageForTransport } from "../agent/image-optimize.js";
|
|
18
|
-
import { createUserMessage } from "../agent/message-types.js";
|
|
19
18
|
import type { AssistantEvent } from "../api/index.js";
|
|
20
19
|
import type {
|
|
21
20
|
TurnChannelContext,
|
|
@@ -76,6 +75,13 @@ import type { MessageQueue } from "./conversation-queue-manager.js";
|
|
|
76
75
|
import type { SlackInboundMessageMetadata } from "./handlers/shared.js";
|
|
77
76
|
import type { UserMessageAttachment } from "./message-protocol.js";
|
|
78
77
|
import type { ConversationTransportMetadata } from "./message-types/conversations.js";
|
|
78
|
+
import {
|
|
79
|
+
assembleUserContentBlocks,
|
|
80
|
+
offloadLinkPlan,
|
|
81
|
+
offloadOversizedText,
|
|
82
|
+
OVERSIZED_CONTENT_FILENAME,
|
|
83
|
+
type PortOversizedContext,
|
|
84
|
+
} from "./port-oversized-content.js";
|
|
79
85
|
import type { TrustContext } from "./trust-context-types.js";
|
|
80
86
|
import { restingTrust } from "./trust-context-types.js";
|
|
81
87
|
import { postUnsendableImageNotice } from "./unsendable-image-notice.js";
|
|
@@ -272,11 +278,8 @@ export async function serializePersistedUserMessageContent(
|
|
|
272
278
|
displayContent: string | undefined,
|
|
273
279
|
attachments: MessageAttachmentInput[],
|
|
274
280
|
): Promise<string> {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
displayContent,
|
|
278
|
-
await attachmentsToContentBlocks(attachments),
|
|
279
|
-
);
|
|
281
|
+
const attachmentBlocks = await attachmentsToContentBlocks(attachments);
|
|
282
|
+
return serializeUserContentBlocks(content, displayContent, attachmentBlocks);
|
|
280
283
|
}
|
|
281
284
|
|
|
282
285
|
/**
|
|
@@ -1158,6 +1161,7 @@ export async function persistQueuedMessageBody(
|
|
|
1158
1161
|
// exactly the shape a link-aware delete reads as collectible, so the cleanup
|
|
1159
1162
|
// must stop at the insert.
|
|
1160
1163
|
let preparedAttachments: PreparedUserAttachment[] = [];
|
|
1164
|
+
let portedAttachmentIds: string[] = [];
|
|
1161
1165
|
let messageInserted = false;
|
|
1162
1166
|
|
|
1163
1167
|
try {
|
|
@@ -1336,22 +1340,42 @@ export async function persistQueuedMessageBody(
|
|
|
1336
1340
|
preparedAttachments,
|
|
1337
1341
|
);
|
|
1338
1342
|
const sightFrameRenames = sightFrameBlockRenames(sightFrameEntries);
|
|
1339
|
-
|
|
1340
|
-
|
|
1343
|
+
|
|
1344
|
+
const portCtx: PortOversizedContext = {
|
|
1345
|
+
conversationId: ctx.conversationId,
|
|
1346
|
+
conversationCreatedAt,
|
|
1347
|
+
};
|
|
1348
|
+
const persistText = displayContent !== undefined ? displayContent : content;
|
|
1349
|
+
const persistOffload = await offloadOversizedText(persistText, portCtx);
|
|
1350
|
+
const liveOffload =
|
|
1351
|
+
persistText === content
|
|
1352
|
+
? persistOffload
|
|
1353
|
+
: await offloadOversizedText(content, portCtx);
|
|
1354
|
+
const attachmentBlocks = preparedAttachments.map((p) => p.block);
|
|
1355
|
+
const persistBlocks = assembleUserContentBlocks(
|
|
1356
|
+
persistOffload.text,
|
|
1357
|
+
attachmentBlocks,
|
|
1358
|
+
persistOffload.fileBlock,
|
|
1359
|
+
);
|
|
1360
|
+
const liveBlocks = assembleUserContentBlocks(
|
|
1361
|
+
liveOffload.text,
|
|
1362
|
+
attachmentBlocks,
|
|
1363
|
+
liveOffload.fileBlock,
|
|
1364
|
+
);
|
|
1365
|
+
const offloadPlan = offloadLinkPlan(
|
|
1366
|
+
persistOffload.attachmentId,
|
|
1367
|
+
liveOffload.attachmentId,
|
|
1368
|
+
);
|
|
1369
|
+
portedAttachmentIds = offloadPlan.linkIds;
|
|
1370
|
+
|
|
1371
|
+
// Workspace-ref blocks (including offloaded oversized text) so the live
|
|
1372
|
+
// turn matches persist: video and huge strings stay off the prompt.
|
|
1341
1373
|
const cleanMessage = restampInlineAttachmentIds(
|
|
1342
|
-
|
|
1374
|
+
{ role: "user", content: liveBlocks },
|
|
1343
1375
|
sightFrameRenames,
|
|
1344
1376
|
);
|
|
1345
1377
|
|
|
1346
|
-
|
|
1347
|
-
// intent stripping), persist that to DB so users see the full message
|
|
1348
|
-
// after restart. The in-memory userMessage (sent to the LLM) still uses
|
|
1349
|
-
// the stripped content.
|
|
1350
|
-
const contentToPersist = serializeUserContentBlocks(
|
|
1351
|
-
content,
|
|
1352
|
-
displayContent,
|
|
1353
|
-
preparedAttachments.map((p) => p.block),
|
|
1354
|
-
);
|
|
1378
|
+
const contentToPersist = JSON.stringify(persistBlocks);
|
|
1355
1379
|
// Composed here rather than with the rest of the metadata above, because
|
|
1356
1380
|
// materialization is what decides the id each frame is stored under.
|
|
1357
1381
|
const sightFrameIds = sightFrameTagIds(sightFrameEntries);
|
|
@@ -1379,6 +1403,7 @@ export async function persistQueuedMessageBody(
|
|
|
1379
1403
|
messageInserted = true;
|
|
1380
1404
|
|
|
1381
1405
|
if (persistedUserMessage.deduplicated) {
|
|
1406
|
+
discardAttemptAttachments(portedAttachmentIds);
|
|
1382
1407
|
return { id: persistedUserMessage.id, deduplicated: true };
|
|
1383
1408
|
}
|
|
1384
1409
|
|
|
@@ -1453,14 +1478,49 @@ export async function persistQueuedMessageBody(
|
|
|
1453
1478
|
if (repairedBlocks) {
|
|
1454
1479
|
updateMessageContent(
|
|
1455
1480
|
persistedUserMessage.id,
|
|
1456
|
-
|
|
1481
|
+
JSON.stringify(
|
|
1482
|
+
assembleUserContentBlocks(
|
|
1483
|
+
persistOffload.text,
|
|
1484
|
+
repairedBlocks,
|
|
1485
|
+
persistOffload.fileBlock,
|
|
1486
|
+
),
|
|
1487
|
+
),
|
|
1457
1488
|
);
|
|
1458
1489
|
}
|
|
1459
1490
|
|
|
1460
|
-
|
|
1461
|
-
|
|
1491
|
+
let nextPortPosition =
|
|
1492
|
+
preparedAttachments.reduce((max, p) => Math.max(max, p.position), -1) + 1;
|
|
1493
|
+
for (const attachmentId of portedAttachmentIds) {
|
|
1494
|
+
try {
|
|
1495
|
+
const scopedId = linkAttachmentToMessage(
|
|
1496
|
+
persistedUserMessage.id,
|
|
1497
|
+
attachmentId,
|
|
1498
|
+
nextPortPosition,
|
|
1499
|
+
);
|
|
1500
|
+
nextPortPosition += 1;
|
|
1501
|
+
const storedPath = getFilePathForAttachment(scopedId) ?? undefined;
|
|
1502
|
+
// Display-only offloads stay linked (GC + persisted workspace_ref)
|
|
1503
|
+
// but must not be named on the model-facing list.
|
|
1504
|
+
if (offloadPlan.modelFacingIds.has(attachmentId)) {
|
|
1505
|
+
sentAttachments.push({
|
|
1506
|
+
filename: OVERSIZED_CONTENT_FILENAME,
|
|
1507
|
+
mimeType: "text/plain",
|
|
1508
|
+
data: "",
|
|
1509
|
+
storedPath,
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
log.error(
|
|
1514
|
+
{ attachmentId, err },
|
|
1515
|
+
"Failed to link offloaded oversized content attachment",
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Same list enrichMessageWithSourcePaths sees, so history reload rebuilds
|
|
1521
|
+
// an identical annotation block (prefix-cache parity).
|
|
1462
1522
|
const attachmentStoredPaths =
|
|
1463
|
-
extractAttachmentStoredPaths(
|
|
1523
|
+
extractAttachmentStoredPaths(sentAttachments);
|
|
1464
1524
|
if (attachmentStoredPaths) {
|
|
1465
1525
|
updateMessageMetadata(persistedUserMessage.id, { attachmentStoredPaths });
|
|
1466
1526
|
}
|
|
@@ -1510,9 +1570,10 @@ export async function persistQueuedMessageBody(
|
|
|
1510
1570
|
ctx.messages.pop();
|
|
1511
1571
|
}
|
|
1512
1572
|
if (!messageInserted) {
|
|
1513
|
-
const undiscarded = discardAttemptAttachments(
|
|
1514
|
-
attemptCreatedAttachmentIds(attachmentInputs, preparedAttachments),
|
|
1515
|
-
|
|
1573
|
+
const undiscarded = discardAttemptAttachments([
|
|
1574
|
+
...attemptCreatedAttachmentIds(attachmentInputs, preparedAttachments),
|
|
1575
|
+
...portedAttachmentIds,
|
|
1576
|
+
]);
|
|
1516
1577
|
if (undiscarded.length > 0) {
|
|
1517
1578
|
options.onUndiscardedAttachments?.(undiscarded);
|
|
1518
1579
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createConversation } from "../persistence/conversation-crud.js";
|
|
4
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
5
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
6
|
+
import {
|
|
7
|
+
assembleUserContentBlocks,
|
|
8
|
+
offloadLinkPlan,
|
|
9
|
+
offloadOversizedText,
|
|
10
|
+
OVERSIZED_CONTENT_FILENAME,
|
|
11
|
+
OVERSIZED_CONTENT_NOTE,
|
|
12
|
+
} from "./port-oversized-content.js";
|
|
13
|
+
|
|
14
|
+
await initializeDb();
|
|
15
|
+
|
|
16
|
+
function resetTables() {
|
|
17
|
+
const db = getDb();
|
|
18
|
+
db.run("DELETE FROM message_attachments");
|
|
19
|
+
db.run("DELETE FROM attachments");
|
|
20
|
+
db.run("DELETE FROM messages");
|
|
21
|
+
db.run("DELETE FROM conversations");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("offloadOversizedText", () => {
|
|
25
|
+
test("leaves text under the cap unchanged", async () => {
|
|
26
|
+
resetTables();
|
|
27
|
+
const conv = createConversation();
|
|
28
|
+
const result = await offloadOversizedText("hello", {
|
|
29
|
+
conversationId: conv.id,
|
|
30
|
+
conversationCreatedAt: conv.createdAt,
|
|
31
|
+
});
|
|
32
|
+
expect(result).toEqual({ text: "hello" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("ports over-cap text into a workspace_ref attachment", async () => {
|
|
36
|
+
resetTables();
|
|
37
|
+
const conv = createConversation();
|
|
38
|
+
const original = "x".repeat(32);
|
|
39
|
+
const result = await offloadOversizedText(
|
|
40
|
+
original,
|
|
41
|
+
{
|
|
42
|
+
conversationId: conv.id,
|
|
43
|
+
conversationCreatedAt: conv.createdAt,
|
|
44
|
+
},
|
|
45
|
+
16,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(result.text).toBe(OVERSIZED_CONTENT_NOTE);
|
|
49
|
+
expect(result.attachmentId).toBeDefined();
|
|
50
|
+
expect(result.fileBlock).toMatchObject({
|
|
51
|
+
type: "file",
|
|
52
|
+
source: {
|
|
53
|
+
type: "workspace_ref",
|
|
54
|
+
media_type: "text/plain",
|
|
55
|
+
filename: OVERSIZED_CONTENT_FILENAME,
|
|
56
|
+
attachmentId: result.attachmentId,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(JSON.stringify(result)).not.toContain(original);
|
|
60
|
+
if (result.fileBlock === undefined) {
|
|
61
|
+
throw new Error("expected an offloaded file block");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const blocks = assembleUserContentBlocks(result.text, [], result.fileBlock);
|
|
65
|
+
expect(JSON.stringify(blocks)).not.toContain(original);
|
|
66
|
+
expect(blocks[0]).toEqual({ type: "text", text: OVERSIZED_CONTENT_NOTE });
|
|
67
|
+
expect(blocks[1]).toEqual(result.fileBlock);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("offloadLinkPlan", () => {
|
|
72
|
+
test("same persist and live id is linked and model-facing", () => {
|
|
73
|
+
const plan = offloadLinkPlan("att-1", "att-1");
|
|
74
|
+
expect(plan.linkIds).toEqual(["att-1"]);
|
|
75
|
+
expect([...plan.modelFacingIds]).toEqual(["att-1"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("display-only persist offload is linked but not model-facing", () => {
|
|
79
|
+
const plan = offloadLinkPlan("att-display", undefined);
|
|
80
|
+
expect(plan.linkIds).toEqual(["att-display"]);
|
|
81
|
+
expect(plan.modelFacingIds.size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("distinct persist and live ids keep only live model-facing", () => {
|
|
85
|
+
const plan = offloadLinkPlan("att-display", "att-live");
|
|
86
|
+
expect(plan.linkIds).toEqual(["att-display", "att-live"]);
|
|
87
|
+
expect([...plan.modelFacingIds]).toEqual(["att-live"]);
|
|
88
|
+
});
|
|
89
|
+
});
|