@frockbot/kernel-contracts 0.0.0 → 0.1.1

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.
@@ -0,0 +1,708 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Context } from "cordis";
3
+ import {
4
+ SESSION_ATTACHMENT_MAX_BASE64,
5
+ SessionStore,
6
+ type SessionStoreConfig,
7
+ validateToolOccurrenceJournal,
8
+ } from "./session.js";
9
+ import {
10
+ decodeSessionEvent,
11
+ type NormalizedModelRequest,
12
+ type SessionEvent,
13
+ type SessionEventInput,
14
+ turnFailureMessage,
15
+ } from "./types.js";
16
+
17
+ const roots: Context[] = [];
18
+ const timestamp = "2026-08-29T00:00:00.000Z";
19
+
20
+ function durableEvents(inputs: SessionEventInput[]): SessionEvent[] {
21
+ return inputs.map((input, seq) => ({
22
+ ...input,
23
+ seq,
24
+ timestamp,
25
+ })) as SessionEvent[];
26
+ }
27
+
28
+ async function createStore(
29
+ initialSessions?: Readonly<Record<string, readonly SessionEvent[]>>,
30
+ config: Omit<SessionStoreConfig, "initialSessions"> = {},
31
+ ): Promise<Context> {
32
+ const root = new Context();
33
+ roots.push(root);
34
+ await root.plugin(SessionStore, { ...config, initialSessions });
35
+ return root;
36
+ }
37
+
38
+ afterEach(async () => {
39
+ await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
40
+ });
41
+
42
+ describe("SessionStore", () => {
43
+ test("accepts resumable tool crash states only while their step is open", () => {
44
+ const assistant = [
45
+ { type: "turn/start" as const, turn: 1 },
46
+ { type: "step/start" as const, turn: 1, step: 1 },
47
+ {
48
+ type: "assistant/message" as const,
49
+ turn: 1,
50
+ step: 1,
51
+ requestId: "request-1",
52
+ text: "",
53
+ toolCalls: [
54
+ { id: "provider-call", name: "write", input: { value: "x" } },
55
+ ],
56
+ },
57
+ ] satisfies SessionEventInput[];
58
+ const intent = {
59
+ type: "tool/call" as const,
60
+ turn: 1,
61
+ step: 1,
62
+ occurrenceId: "tool:1:1:0",
63
+ name: "write",
64
+ input: { value: "x" },
65
+ } satisfies SessionEventInput;
66
+
67
+ const unjournaled = validateToolOccurrenceJournal(
68
+ durableEvents(assistant),
69
+ ).get("tool:1:1:0");
70
+ expect(unjournaled?.intent).toBeUndefined();
71
+ expect(unjournaled?.result).toBeUndefined();
72
+ const journaled = validateToolOccurrenceJournal(
73
+ durableEvents([...assistant, intent]),
74
+ ).get("tool:1:1:0");
75
+ expect(journaled?.intent).toMatchObject(intent);
76
+ expect(journaled?.result).toBeUndefined();
77
+ });
78
+
79
+ test.each([
80
+ [
81
+ "tool intent after step end",
82
+ [
83
+ { type: "turn/start" as const, turn: 1 },
84
+ { type: "step/start" as const, turn: 1, step: 1 },
85
+ {
86
+ type: "assistant/message" as const,
87
+ turn: 1,
88
+ step: 1,
89
+ requestId: "request-1",
90
+ text: "",
91
+ toolCalls: [
92
+ { id: "provider-call", name: "write", input: { value: "x" } },
93
+ ],
94
+ },
95
+ {
96
+ type: "step/end" as const,
97
+ turn: 1,
98
+ step: 1,
99
+ outcome: "completed" as const,
100
+ },
101
+ {
102
+ type: "tool/call" as const,
103
+ turn: 1,
104
+ step: 1,
105
+ occurrenceId: "tool:1:1:0",
106
+ name: "write",
107
+ input: { value: "x" },
108
+ },
109
+ ],
110
+ "was not settled before step end",
111
+ ],
112
+ [
113
+ "tool result after turn end",
114
+ [
115
+ { type: "turn/start" as const, turn: 1 },
116
+ { type: "step/start" as const, turn: 1, step: 1 },
117
+ {
118
+ type: "assistant/message" as const,
119
+ turn: 1,
120
+ step: 1,
121
+ requestId: "request-1",
122
+ text: "",
123
+ toolCalls: [
124
+ { id: "provider-call", name: "write", input: { value: "x" } },
125
+ ],
126
+ },
127
+ {
128
+ type: "tool/call" as const,
129
+ turn: 1,
130
+ step: 1,
131
+ occurrenceId: "tool:1:1:0",
132
+ name: "write",
133
+ input: { value: "x" },
134
+ },
135
+ {
136
+ type: "tool/result" as const,
137
+ turn: 1,
138
+ step: 1,
139
+ occurrenceId: "tool:1:1:0",
140
+ name: "write",
141
+ content: "done",
142
+ isError: false,
143
+ status: "completed" as const,
144
+ },
145
+ {
146
+ type: "step/end" as const,
147
+ turn: 1,
148
+ step: 1,
149
+ outcome: "completed" as const,
150
+ },
151
+ { type: "turn/end" as const, turn: 1, outcome: "completed" as const },
152
+ {
153
+ type: "tool/result" as const,
154
+ turn: 1,
155
+ step: 1,
156
+ occurrenceId: "tool:1:1:0",
157
+ name: "write",
158
+ content: "duplicate",
159
+ isError: false,
160
+ status: "completed" as const,
161
+ },
162
+ ],
163
+ "outside its open step",
164
+ ],
165
+ [
166
+ "mismatched step end",
167
+ [
168
+ { type: "turn/start" as const, turn: 1 },
169
+ { type: "step/start" as const, turn: 1, step: 1 },
170
+ {
171
+ type: "step/end" as const,
172
+ turn: 1,
173
+ step: 2,
174
+ outcome: "completed" as const,
175
+ },
176
+ ],
177
+ "ended without its matching start",
178
+ ],
179
+ [
180
+ "turn end with an open step",
181
+ [
182
+ { type: "turn/start" as const, turn: 1 },
183
+ { type: "step/start" as const, turn: 1, step: 1 },
184
+ { type: "turn/end" as const, turn: 1, outcome: "completed" as const },
185
+ ],
186
+ "ended while step 1 is open",
187
+ ],
188
+ [
189
+ "nested turn start",
190
+ [
191
+ { type: "turn/start" as const, turn: 1 },
192
+ { type: "turn/start" as const, turn: 2 },
193
+ ],
194
+ "started while turn 1 is open",
195
+ ],
196
+ ])(
197
+ "rejects adversarial lifecycle ordering: %s",
198
+ (_label, inputs, message) => {
199
+ expect(() =>
200
+ validateToolOccurrenceJournal(
201
+ durableEvents(inputs as SessionEventInput[]),
202
+ ),
203
+ ).toThrow(message as string);
204
+ },
205
+ );
206
+
207
+ test("replays the exact request under its recorded Composition generation", async () => {
208
+ const root = await createStore();
209
+ const session = root.sessions.create("session-1");
210
+ const request: NormalizedModelRequest = {
211
+ requestId: "request-1",
212
+ provider: "scripted",
213
+ model: "test",
214
+ system: "Be concise.",
215
+ messages: [{ role: "user", content: "Hello" }],
216
+ tools: [],
217
+ };
218
+
219
+ session.appendBatch([
220
+ { type: "turn/start", turn: 1 },
221
+ {
222
+ type: "composition/pinned",
223
+ turn: 1,
224
+ generationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
225
+ artifactSetHash: "a".repeat(64),
226
+ },
227
+ { type: "input/admitted", messageId: "message-1", turn: 1 },
228
+ { type: "step/start", turn: 1, step: 1 },
229
+ {
230
+ type: "user/message",
231
+ turn: 1,
232
+ step: 1,
233
+ messageId: "message-1",
234
+ text: "Hello",
235
+ },
236
+ { type: "model/request", turn: 1, step: 1, request },
237
+ {
238
+ type: "assistant/message",
239
+ turn: 1,
240
+ step: 1,
241
+ requestId: "request-1",
242
+ text: "Hi",
243
+ toolCalls: [],
244
+ },
245
+ // Self-modification is a durable effect the log reconstructs: the intent
246
+ // is recorded before the bundler runs, the outcome after it.
247
+ {
248
+ type: "package/author-intent",
249
+ turn: 1,
250
+ step: 1,
251
+ effectId: "author-0123456789abcdef",
252
+ packageId: "weather-lookup",
253
+ sourceHash: "c".repeat(64),
254
+ },
255
+ {
256
+ type: "package/authored",
257
+ turn: 1,
258
+ step: 1,
259
+ effectId: "author-0123456789abcdef",
260
+ packageId: "weather-lookup",
261
+ version: "0.0.1",
262
+ contentHash: "d".repeat(64),
263
+ generationId: "2026-08-31T01:00:00.000Z:fedcba9876543210",
264
+ },
265
+ { type: "step/end", turn: 1, step: 1, outcome: "completed" },
266
+ { type: "turn/end", turn: 1, outcome: "completed" },
267
+ ]);
268
+
269
+ const recorded = session.events.find(
270
+ (event) => event.type === "model/request",
271
+ );
272
+ const pin = session.events.find(
273
+ (event) => event.type === "composition/pinned",
274
+ );
275
+ expect(
276
+ recorded?.type === "model/request" ? recorded.request : undefined,
277
+ ).toEqual(request);
278
+ expect(
279
+ pin?.type === "composition/pinned"
280
+ ? {
281
+ generationId: pin.generationId,
282
+ artifactSetHash: pin.artifactSetHash,
283
+ }
284
+ : undefined,
285
+ ).toEqual({
286
+ generationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
287
+ artifactSetHash: "a".repeat(64),
288
+ });
289
+ const authored = session.events.find(
290
+ (event) => event.type === "package/authored",
291
+ );
292
+ expect(
293
+ authored?.type === "package/authored"
294
+ ? {
295
+ effectId: authored.effectId,
296
+ packageId: authored.packageId,
297
+ version: authored.version,
298
+ generationId: authored.generationId,
299
+ }
300
+ : undefined,
301
+ ).toEqual({
302
+ effectId: "author-0123456789abcdef",
303
+ packageId: "weather-lookup",
304
+ version: "0.0.1",
305
+ generationId: "2026-08-31T01:00:00.000Z:fedcba9876543210",
306
+ });
307
+ // The authoring events belong to the pinned generation, not the one they
308
+ // produced: activation is at the next admitted Turn.
309
+ expect(
310
+ authored?.type === "package/authored"
311
+ ? authored.generationId ===
312
+ (pin?.type === "composition/pinned" ? pin.generationId : "")
313
+ : undefined,
314
+ ).toBe(false);
315
+ expect(
316
+ session.events.map((event) => decodeSessionEvent(structuredClone(event))),
317
+ ).toEqual([...session.events]);
318
+ expect(session.deriveMessages()).toEqual([
319
+ { role: "user", content: "Hello" },
320
+ { role: "assistant", content: "Hi", toolCalls: [] },
321
+ ]);
322
+ expect(session.events.map((event) => event.seq)).toEqual(
323
+ session.events.map((_, index) => index),
324
+ );
325
+ });
326
+
327
+ test("flushes appended events through the durable seam in order", async () => {
328
+ const persisted: Array<{ sessionId: string; types: string[] }> = [];
329
+ const root = await createStore(undefined, {
330
+ persistEvents: async (sessionId, events) => {
331
+ await Promise.resolve();
332
+ persisted.push({
333
+ sessionId,
334
+ types: events.map((event) => event.type),
335
+ });
336
+ },
337
+ });
338
+ const session = root.sessions.create("durable-session");
339
+ session.appendBatch([
340
+ { type: "turn/start", turn: 1 },
341
+ { type: "turn/end", turn: 1, outcome: "completed" },
342
+ ]);
343
+
344
+ expect(persisted).toEqual([]);
345
+ await session.flush();
346
+ expect(persisted).toEqual([
347
+ { sessionId: "durable-session", types: ["session/created"] },
348
+ {
349
+ sessionId: "durable-session",
350
+ types: ["turn/start", "turn/end"],
351
+ },
352
+ ]);
353
+ });
354
+
355
+ test("rehydrates a session and continues its sequence", async () => {
356
+ const firstRoot = await createStore();
357
+ const first = firstRoot.sessions.create("durable-session");
358
+ first.appendBatch([
359
+ { type: "turn/start", turn: 1 },
360
+ { type: "turn/end", turn: 1, outcome: "completed" },
361
+ ]);
362
+ const stored = structuredClone([...first.events]);
363
+
364
+ const secondRoot = await createStore({ "durable-session": stored });
365
+ const rehydrated = secondRoot.sessions.create("durable-session");
366
+ expect(rehydrated.events).toEqual(stored);
367
+ expect(rehydrated.nextTurn()).toBe(2);
368
+ expect(rehydrated.append({ type: "turn/start", turn: 2 }).seq).toBe(3);
369
+ });
370
+
371
+ test("rejects a non-contiguous durable event log", async () => {
372
+ const root = await createStore({
373
+ broken: [
374
+ {
375
+ type: "session/created",
376
+ createdAt: "2026-08-27T00:00:00.000Z",
377
+ seq: 1,
378
+ timestamp: "2026-08-27T00:00:00.000Z",
379
+ },
380
+ ],
381
+ });
382
+ expect(() => root.sessions.create("broken")).toThrow(
383
+ "non-contiguous event log",
384
+ );
385
+ });
386
+
387
+ test("preserves open tool intents for effect reconciliation on resume", async () => {
388
+ const root = await createStore();
389
+ const session = root.sessions.create("session-resume-tool");
390
+ session.appendBatch([
391
+ { type: "turn/start", turn: 1 },
392
+ { type: "input/admitted", messageId: "message-1", turn: 1 },
393
+ { type: "step/start", turn: 1, step: 1 },
394
+ {
395
+ type: "assistant/message",
396
+ turn: 1,
397
+ step: 1,
398
+ requestId: "request-1",
399
+ text: "",
400
+ toolCalls: [{ id: "call-1", name: "write", input: { value: "x" } }],
401
+ },
402
+ {
403
+ type: "tool/call",
404
+ turn: 1,
405
+ step: 1,
406
+ occurrenceId: "tool:1:1:0",
407
+ name: "write",
408
+ input: { value: "x" },
409
+ },
410
+ ]);
411
+
412
+ expect(session.reconcileForResume()).toEqual([]);
413
+ expect(
414
+ validateToolOccurrenceJournal(session.events).get("tool:1:1:0"),
415
+ ).toMatchObject({ intent: { occurrenceId: "tool:1:1:0" } });
416
+ expect(
417
+ session.events.some(
418
+ (event) =>
419
+ event.type === "tool/result" ||
420
+ event.type === "step/end" ||
421
+ event.type === "turn/end",
422
+ ),
423
+ ).toBe(false);
424
+ });
425
+
426
+ test("reconciles unmatched tools, steps, and turns in order", async () => {
427
+ const root = await createStore();
428
+ const session = root.sessions.create("session-2");
429
+ session.appendBatch([
430
+ { type: "turn/start", turn: 1 },
431
+ {
432
+ type: "composition/pinned",
433
+ turn: 1,
434
+ generationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
435
+ artifactSetHash: "a".repeat(64),
436
+ },
437
+ { type: "input/admitted", messageId: "message-1", turn: 1 },
438
+ { type: "step/start", turn: 1, step: 1 },
439
+ {
440
+ type: "assistant/message",
441
+ turn: 1,
442
+ step: 1,
443
+ requestId: "request-1",
444
+ text: "",
445
+ toolCalls: [{ id: "call-1", name: "write", input: { value: "x" } }],
446
+ },
447
+ {
448
+ type: "tool/call",
449
+ turn: 1,
450
+ step: 1,
451
+ occurrenceId: "tool:1:1:0",
452
+ name: "write",
453
+ input: { value: "x" },
454
+ },
455
+ ]);
456
+
457
+ const repaired = session.reconcileInterrupted();
458
+ expect(repaired.map((event) => event.type)).toEqual([
459
+ "tool/result",
460
+ "step/end",
461
+ "turn/end",
462
+ ]);
463
+ expect(
464
+ repaired.find((event) => event.type === "tool/result"),
465
+ ).toMatchObject({
466
+ occurrenceId: "tool:1:1:0",
467
+ status: "interrupted",
468
+ isError: true,
469
+ });
470
+ expect(session.deriveMessages().at(-1)).toMatchObject({
471
+ role: "tool",
472
+ callId: "call-1",
473
+ });
474
+ expect(session.reconcileInterrupted()).toEqual([]);
475
+ });
476
+
477
+ test("disposes all live sessions with its Cordis fiber", async () => {
478
+ const root = await createStore();
479
+ const session = root.sessions.create("session-3");
480
+ await root.fiber.dispose();
481
+ roots.splice(roots.indexOf(root), 1);
482
+
483
+ expect(session.disposed).toBe(true);
484
+ expect(session.events.at(-1)?.type).toBe("session/disposed");
485
+ });
486
+ test("decodes invoked Skills on an input, and refuses a malformed one", () => {
487
+ const base = {
488
+ type: "input/queued" as const,
489
+ seq: 0,
490
+ timestamp,
491
+ messageId: "message-1",
492
+ text: "run the standup",
493
+ };
494
+ // An input recorded before invocation existed still decodes unchanged.
495
+ expect(decodeSessionEvent(structuredClone(base))).toEqual(base);
496
+ const invoking = {
497
+ ...base,
498
+ skills: [
499
+ { schemaVersion: 1 as const, source: "bot" as const, slug: "s" },
500
+ ],
501
+ };
502
+ expect(decodeSessionEvent(structuredClone(invoking))).toEqual(invoking);
503
+ expect(() =>
504
+ decodeSessionEvent({ ...base, skills: [{ source: "bot", slug: "s" }] }),
505
+ ).toThrow();
506
+ expect(() => decodeSessionEvent({ ...base, skills: "bot/s" })).toThrow();
507
+ });
508
+
509
+ test("decodes skill/invoked with exact keys and a decoded ref", () => {
510
+ const event = {
511
+ type: "skill/invoked" as const,
512
+ seq: 0,
513
+ timestamp,
514
+ turn: 1,
515
+ ref: { schemaVersion: 1 as const, source: "bot" as const, slug: "s" },
516
+ generationId: "1970-01-01T00:00:00.000Z:0123456789abcdef",
517
+ contentHash: "a".repeat(64),
518
+ };
519
+ expect(decodeSessionEvent(structuredClone(event))).toEqual(event);
520
+ expect(() => decodeSessionEvent({ ...event, step: 1 })).toThrow(
521
+ "session event has invalid fields",
522
+ );
523
+ expect(() =>
524
+ decodeSessionEvent({
525
+ ...event,
526
+ ref: { schemaVersion: 1, source: "workflow", slug: "s" },
527
+ }),
528
+ ).toThrow();
529
+ });
530
+
531
+ test("decodes a turn/end reason only within its declared bound", () => {
532
+ const base = {
533
+ type: "turn/end" as const,
534
+ seq: 0,
535
+ timestamp,
536
+ turn: 1,
537
+ outcome: "model-error" as const,
538
+ };
539
+ const withReason = {
540
+ ...base,
541
+ reason: "Ollama Cloud responded 401: invalid api key",
542
+ };
543
+ expect(decodeSessionEvent(structuredClone(withReason))).toEqual(withReason);
544
+ expect(
545
+ decodeSessionEvent(structuredClone({ ...base, reason: "x".repeat(500) })),
546
+ ).toMatchObject({ reason: "x".repeat(500) });
547
+ expect(() =>
548
+ decodeSessionEvent({ ...base, reason: "x".repeat(501) }),
549
+ ).toThrow("session event.reason is too long");
550
+ expect(() => decodeSessionEvent({ ...base, reason: "" })).toThrow(
551
+ "session event.reason must be a string",
552
+ );
553
+ expect(() =>
554
+ decodeSessionEvent({ ...base, reason: "why", cause: "extra" }),
555
+ ).toThrow("session event has invalid fields");
556
+ });
557
+
558
+ test("decodes a rename announcement and refuses a malformed one", () => {
559
+ // A rename happens outside any Turn, so the event carries no turn or step
560
+ // and every other session event keeps decoding exactly as before.
561
+ const renamed = {
562
+ type: "bot/renamed" as const,
563
+ seq: 4,
564
+ timestamp,
565
+ from: "Housework",
566
+ to: "Atlas",
567
+ namedBy: "bot" as const,
568
+ };
569
+ expect(decodeSessionEvent(structuredClone(renamed))).toEqual(renamed);
570
+ expect(() => decodeSessionEvent({ ...renamed, namedBy: "admin" })).toThrow(
571
+ "session event.namedBy is invalid",
572
+ );
573
+ expect(() => decodeSessionEvent({ ...renamed, from: "" })).toThrow(
574
+ "session event.from must be a string",
575
+ );
576
+ expect(() => decodeSessionEvent({ ...renamed, turn: 1 })).toThrow(
577
+ "session event has invalid fields",
578
+ );
579
+ });
580
+
581
+ test("carries the Bot and Turn that renamed the Bot, when one did", () => {
582
+ const writer = {
583
+ kind: "bot" as const,
584
+ botId: "bot-1",
585
+ sessionId: "user-1:bot-1",
586
+ turnId: "turn-4",
587
+ };
588
+ const renamed = {
589
+ type: "bot/renamed" as const,
590
+ seq: 4,
591
+ timestamp,
592
+ from: "Housework",
593
+ to: "Atlas",
594
+ namedBy: "bot" as const,
595
+ writer,
596
+ };
597
+ expect(decodeSessionEvent(structuredClone(renamed))).toEqual(renamed);
598
+ // Only a Bot writer exists, so a User rename can never carry one.
599
+ expect(() => decodeSessionEvent({ ...renamed, namedBy: "user" })).toThrow(
600
+ "session event.writer is invalid",
601
+ );
602
+ expect(() =>
603
+ decodeSessionEvent({ ...renamed, writer: { ...writer, kind: "user" } }),
604
+ ).toThrow("session event.writer.kind is invalid");
605
+ expect(() =>
606
+ decodeSessionEvent({ ...renamed, writer: { ...writer, extra: 1 } }),
607
+ ).toThrow("session event.writer has invalid fields");
608
+ });
609
+
610
+ test("composes a failure message from a turn outcome and its reason", () => {
611
+ expect(turnFailureMessage("model-error", "provider said no")).toBe(
612
+ "Bot turn ended with outcome model-error: provider said no",
613
+ );
614
+ expect(turnFailureMessage("interrupted")).toBe(
615
+ "Bot turn ended with outcome interrupted",
616
+ );
617
+ });
618
+ });
619
+
620
+ describe("resolved attachment bytes", () => {
621
+ const attachment = {
622
+ kind: "image" as const,
623
+ mediaType: "image/png" as const,
624
+ workspacePath: {
625
+ root: {
626
+ kind: "package-declared" as const,
627
+ userId: "user-1",
628
+ packageId: "computer",
629
+ rootId: "screenshots",
630
+ },
631
+ path: "bot-1/run-9-1.png",
632
+ },
633
+ contentHash: "c".repeat(64),
634
+ bytes: 3,
635
+ };
636
+
637
+ async function sessionWithScreenshot() {
638
+ const root = await createStore();
639
+ const session = root.sessions.create("session-1");
640
+ session.append({ type: "turn/start", turn: 1 });
641
+ session.append({ type: "step/start", turn: 1, step: 1 });
642
+ session.append({
643
+ type: "assistant/message",
644
+ turn: 1,
645
+ step: 1,
646
+ requestId: "request-1",
647
+ text: "",
648
+ toolCalls: [{ id: "call-1", name: "computer_screenshot", input: {} }],
649
+ });
650
+ session.append({
651
+ type: "tool/call",
652
+ turn: 1,
653
+ step: 1,
654
+ occurrenceId: "tool:1:1:0",
655
+ name: "computer_screenshot",
656
+ input: {},
657
+ });
658
+ session.append({
659
+ type: "tool/result",
660
+ turn: 1,
661
+ step: 1,
662
+ occurrenceId: "tool:1:1:0",
663
+ name: "computer_screenshot",
664
+ content: "{}",
665
+ isError: false,
666
+ status: "completed",
667
+ attachments: [attachment],
668
+ });
669
+ return session;
670
+ }
671
+
672
+ // The reference is durable and the bytes are not: while the Session is
673
+ // resident the request carries the picture, and on the far side of an
674
+ // eviction it carries the path, which is the observable outcome rather than
675
+ // a silent one.
676
+ test("reaches the derived request only while the Session holds them", async () => {
677
+ const session = await sessionWithScreenshot();
678
+
679
+ const before = session.deriveMessages().at(-1);
680
+ expect(before).toMatchObject({ attachments: [attachment] });
681
+
682
+ session.offerAttachmentBytes(attachment.contentHash, "AAAA");
683
+ const after = session.deriveMessages().at(-1);
684
+ expect(after).toMatchObject({
685
+ attachments: [{ ...attachment, dataBase64: "AAAA" }],
686
+ });
687
+
688
+ // And they never become durable: the event still holds a reference only.
689
+ const recorded = session.events.findLast(
690
+ (event) => event.type === "tool/result",
691
+ );
692
+ expect(JSON.stringify(recorded)).not.toContain("AAAA");
693
+ });
694
+
695
+ test("refuses an offer that is not a content hash or is oversized", async () => {
696
+ const session = await sessionWithScreenshot();
697
+
698
+ session.offerAttachmentBytes("not-a-hash", "AAAA");
699
+ session.offerAttachmentBytes(
700
+ attachment.contentHash,
701
+ "A".repeat(SESSION_ATTACHMENT_MAX_BASE64 + 1),
702
+ );
703
+
704
+ expect(session.deriveMessages().at(-1)).toMatchObject({
705
+ attachments: [attachment],
706
+ });
707
+ });
708
+ });