@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.8-dev.202609022016.7bd8f87",
3
+ "version": "0.11.8-dev.202609022114.a0a4378",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- const inMemoryFileBlock = inMemoryMessage.content[1] as unknown as Record<
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: "base64",
420
+ type: "workspace_ref",
426
421
  media_type: "application/pdf",
427
- data: Buffer.from("pdf bytes").toString("base64"),
422
+ attachmentId: "att-stored",
423
+ sizeBytes: 9,
428
424
  filename: "attachment.pdf",
429
425
  },
430
- extracted_text: undefined,
431
426
  });
432
427
  });
433
428
 
@@ -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: ref.extractedText,
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
- extracted_text: attachment.extractedText,
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 Settings toggle 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.",
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
- return serializeUserContentBlocks(
276
- content,
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
- // The blocks are built from the ids the caller held, so a cloned frame is
1340
- // pointed at its stored row before the message reaches the live history.
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
- await createUserMessage(content, sentAttachments),
1374
+ { role: "user", content: liveBlocks },
1343
1375
  sightFrameRenames,
1344
1376
  );
1345
1377
 
1346
- // When displayContent is provided (e.g. original text before recording
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
- serializeUserContentBlocks(content, displayContent, repairedBlocks),
1481
+ JSON.stringify(
1482
+ assembleUserContentBlocks(
1483
+ persistOffload.text,
1484
+ repairedBlocks,
1485
+ persistOffload.fileBlock,
1486
+ ),
1487
+ ),
1457
1488
  );
1458
1489
  }
1459
1490
 
1460
- // Persist the resolved paths so history reloads can rebuild the same
1461
- // annotation block the in-memory message carries below.
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(attachmentInputs);
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
+ });