1import {
2 MessageId,
3 NodeId,
4 RunId,
5 ThreadId,
6 TurnItemId,
7 type OrchestrationV2ProjectedTurnItem,
8 type OrchestrationV2TurnItem,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";
12
13import {
14 buildBoundedThreadProjection,
15 boundedTimelineEncodedBytes,
16 computeLatestLocalTurnOrdinal,
17 decodeThreadHistoryCursor,
18 encodeThreadHistoryCursor,
19 InvalidThreadHistoryCursorError,
20 projectedRowBoundedSnapshotEncodedBytes,
21 projectedRowEncodedBytes,
22 selectHistoryPageFromCursor,
23 selectRecentTimelineWindow,
24 THREAD_HISTORY_CURSOR_MAX_LENGTH,
25 THREAD_HISTORY_PAGE_POLICY,
26} from "./threadHistoryPaging.ts";
27import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts";
28import { projectThreadProjectionForWire } from "./WireProjection.ts";
29
30const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
31const THREAD = ThreadId.make("thread-1");
32const RUN = RunId.make("run-interrupt-1");
33const NODE = NodeId.make("node-interrupt-1");
34
35function makeRow(
36 index: number,
37 options?: { readonly outputBytes?: number },
38): OrchestrationV2ProjectedTurnItem {
39 const id = TurnItemId.make(`item-${index}`);
40 const output =
41 options?.outputBytes !== undefined ? "x".repeat(options.outputBytes) : `output-${index}`;
42 return {
43 position: index,
44 visibility: "local",
45 sourceThreadId: THREAD,
46 sourceItemId: id,
47 item: {
48 id,
49 type: "command_execution",
50 threadId: THREAD,
51 runId: null,
52 nodeId: null,
53 providerThreadId: null,
54 providerTurnId: null,
55 nativeItemRef: null,
56 parentItemId: null,
57 ordinal: index + 1,
58 status: "completed",
59 title: `Command ${index}`,
60 input: `cmd-${index}`,
61 output,
62 exitCode: 0,
63 startedAt: NOW,
64 completedAt: NOW,
65 updatedAt: NOW,
66 },
67 };
68}
69
70function interruptItem(
71 id: string,
72 type: "run_interrupt_request" | "run_interrupt_result",
73 ordinal: number,
74): OrchestrationV2TurnItem {
75 return {
76 id: TurnItemId.make(id),
77 type,
78 threadId: THREAD,
79 runId: RUN,
80 nodeId: NODE,
81 providerThreadId: null,
82 providerTurnId: null,
83 nativeItemRef: null,
84 parentItemId: null,
85 ordinal,
86 status: "completed",
87 title: type,
88 message: type === "run_interrupt_request" ? "Stopping" : "Stopped",
89 startedAt: NOW,
90 completedAt: NOW,
91 updatedAt: NOW,
92 } as OrchestrationV2TurnItem;
93}
94
95function interruptRow(
96 index: number,
97 type: "run_interrupt_request" | "run_interrupt_result",
98): OrchestrationV2ProjectedTurnItem {
99 const item = interruptItem(
100 type === "run_interrupt_request" ? "interrupt-req" : "interrupt-res",
101 type,
102 index + 1,
103 );
104 return {
105 position: index,
106 visibility: "local",
107 sourceThreadId: THREAD,
108 sourceItemId: item.id,
109 item,
110 };
111}
112
113function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) {
114 return {
115 thread: {
116 id: THREAD,
117 projectId: "project-1",
118 title: "Thread",
119 providerInstanceId: "codex",
120 modelSelection: { instanceId: "codex", model: "gpt-5" },
121 runtimeMode: "full-access",
122 interactionMode: "default",
123 branch: null,
124 worktreePath: null,
125 activeProviderThreadId: null,
126 lineage: {
127 parentThreadId: null,
128 relationshipToParent: null,
129 rootThreadId: THREAD,
130 },
131 forkedFrom: null,
132 createdBy: "user",
133 creationSource: "web",
134 createdAt: NOW,
135 updatedAt: NOW,
136 archivedAt: null,
137 deletedAt: null,
138 settledOverride: null,
139 settledAt: null,
140 },
141 runs: [{ id: "run-1" }],
142 attempts: [{ id: "attempt-1" }],
143 nodes: [],
144 subagents: [],
145 providerSessions: [],
146 providerThreads: [],
147 providerTurns: [],
148 runtimeRequests: [{ id: "req-1" }],
149 messages: [],
150 plans: [],
151 turnItems: visibleTurnItems.map((row) => row.item),
152 checkpointScopes: [],
153 checkpoints: [],
154 contextHandoffs: [],
155 contextTransfers: [],
156 visibleTurnItems,
157 updatedAt: NOW,
158 } as unknown as Parameters<typeof buildBoundedThreadProjection>[0]["projection"];
159}
160
161describe("threadHistoryPaging", () => {
162 it("builds a resumable bounded socket snapshot frame", () => {
163 const projection = makeProjection(Array.from({ length: 90 }, (_, index) => makeRow(index)));
164 const item = buildBoundedThreadStreamSnapshot({
165 snapshotSequence: 23,
166 projection,
167 });
168
169 expect(item.kind).toBe("snapshot");
170 expect(item.snapshotSequence).toBe(23);
171 expect(item.projection.visibleTurnItems).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems);
172 expect(item.historyCursor).not.toBeNull();
173 expect(item.hasMoreHistory).toBe(true);
174 expect(item.latestLocalTurnOrdinal).toBe(90);
175 expect(item.payloadBudgetExceeded).toBe(false);
176 });
177
178 it("keeps background turns with user turns, with main's 150-turn fan-out ceiling", () => {
179 const items = Array.from({ length: 161 }, (_, turn) => {
180 const row = makeRow(turn * 2);
181 if (row.item.type !== "command_execution") throw new Error("Expected command fixture");
182 const prompt: OrchestrationV2ProjectedTurnItem = {
183 ...row,
184 item: {
185 ...row.item,
186 type: "user_message",
187 createdBy: turn === 0 ? "user" : "agent",
188 creationSource: "provider",
189 inputIntent: "turn_start",
190 messageId: MessageId.make(`prompt-${turn}`),
191 text: `Prompt ${turn}`,
192 attachments: [],
193 },
194 };
195 return [prompt, makeRow(turn * 2 + 1)];
196 }).flat();
197 const first = selectRecentTimelineWindow({ items, snapshotSequence: 1 });
198 expect(first.items).toHaveLength(300);
199 expect(first.items[0]?.sourceItemId).toBe("item-22");
200 const older = selectHistoryPageFromCursor({
201 items,
202 cursor: first.nextCursor!,
203 snapshotSequence: 1,
204 });
205 expect(older.items).toHaveLength(22);
206 expect(older.hasMoreHistory).toBe(false);
207 expect([...older.items, ...first.items].map((row) => row.sourceItemId)).toEqual(
208 items.map((row) => row.sourceItemId),
209 );
210 });
211
212 it("counts runs that a notification started toward the 150-turn ceiling", () => {
213 // A wake (PR watch, task completion) starts its run with a notification. A
214 // row of the run (a handoff) can come before it, and a wake steered into the
215 // running turn comes after it and starts no turn.
216 const inRun = (index: number, run: number, item: object = {}) => {
217 const row = makeRow(index);
218 return {
219 ...row,
220 item: { ...row.item, runId: RunId.make(`run-${run}`), ...item },
221 } as OrchestrationV2ProjectedTurnItem;
222 };
223 const notification = (summary: string) => ({
224 type: "notification",
225 source: { kind: "monitor" },
226 outcome: "completed",
227 summary,
228 });
229 const items = Array.from({ length: 161 }, (_, run) => [
230 inRun(run * 4, run),
231 inRun(
232 run * 4 + 1,
233 run,
234 run === 0
235 ? {
236 type: "user_message",
237 createdBy: "user",
238 creationSource: "web",
239 inputIntent: "turn_start",
240 messageId: MessageId.make("prompt-0"),
241 text: "Prompt 0",
242 attachments: [],
243 }
244 : notification(`Wake ${run}`),
245 ),
246 inRun(run * 4 + 2, run, notification(`Steer ${run}`)),
247 inRun(run * 4 + 3, run),
248 ]).flat();
249 const first = selectRecentTimelineWindow({ items, snapshotSequence: 1 });
250 // 150 runs from the start of run 11. Its earlier row goes to the older page.
251 expect(first.items).toHaveLength(150 * 4 - 1);
252 expect(first.items[0]?.sourceItemId).toBe(`item-${11 * 4 + 1}`);
253 const older = selectHistoryPageFromCursor({
254 items,
255 cursor: first.nextCursor!,
256 snapshotSequence: 1,
257 });
258 expect([...older.items, ...first.items].map((row) => row.sourceItemId)).toEqual(
259 items.map((row) => row.sourceItemId),
260 );
261 });
262
263 it("pages agent-only child transcripts instead of dropping their earlier activity", () => {
264 const commandRows = Array.from({ length: 90 }, (_, index) => makeRow(index + 1));
265 const first = makeRow(0);
266 const prompt = {
267 ...first,
268 item: {
269 ...first.item,
270 type: "user_message" as const,
271 createdBy: "agent" as const,
272 creationSource: "provider" as const,
273 inputIntent: "turn_start" as const,
274 messageId: MessageId.make("child-prompt"),
275 text: "Inspect this project",
276 attachments: [],
277 },
278 } as OrchestrationV2ProjectedTurnItem;
279 const items = [prompt, ...commandRows];
280 const recent = selectRecentTimelineWindow({ items, snapshotSequence: 1 });
281
282 expect(recent.items).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems);
283 expect(recent.hasMoreHistory).toBe(true);
284 const older = selectHistoryPageFromCursor({
285 items,
286 cursor: recent.nextCursor!,
287 snapshotSequence: 1,
288 });
289 expect(older.items[0]?.item.type).toBe("user_message");
290 expect([...older.items, ...recent.items]).toHaveLength(items.length);
291 });
292
293 it("encodes opaque cursors with stable source identity", () => {
294 const cursor = encodeThreadHistoryCursor({
295 snapshotSequence: 9,
296 sourceThreadId: THREAD,
297 sourceItemId: TurnItemId.make("item-3"),
298 position: 3,
299 });
300 expect(cursor.includes("{")).toBe(false);
301 expect(decodeThreadHistoryCursor(cursor)).toEqual({
302 v: 1,
303 seq: 9,
304 st: "thread-1",
305 si: "item-3",
306 p: 3,
307 });
308 });
309
310 it("rejects malformed cursors", () => {
311 expect(() => decodeThreadHistoryCursor("not-valid")).toThrow(InvalidThreadHistoryCursorError);
312 expect(() =>
313 decodeThreadHistoryCursor(Buffer.from("{}", "utf8").toString("base64url")),
314 ).toThrow(InvalidThreadHistoryCursorError);
315 });
316
317 it("rejects cursors longer than the maximum length before decoding", () => {
318 const oversized = "A".repeat(THREAD_HISTORY_CURSOR_MAX_LENGTH + 1);
319 expect(() => decodeThreadHistoryCursor(oversized)).toThrow(InvalidThreadHistoryCursorError);
320 });
321
322 it("selects a recent window by item count", () => {
323 const items = Array.from({ length: 120 }, (_, index) => makeRow(index));
324 const page = selectRecentTimelineWindow({
325 items,
326 snapshotSequence: 4,
327 policy: { maxItems: 10, maxEncodedBytes: 10_000_000 },
328 });
329 expect(page.items).toHaveLength(10);
330 expect(page.items[0]?.sourceItemId).toBe("item-110");
331 expect(page.items[9]?.sourceItemId).toBe("item-119");
332 expect(page.hasMoreHistory).toBe(true);
333 expect(page.nextCursor).not.toBeNull();
334 const cursor = decodeThreadHistoryCursor(page.nextCursor!);
335 expect(cursor.si).toBe("item-110");
336 expect(cursor.seq).toBe(4);
337 });
338
339 it("always includes at least one pathological oversized item", () => {
340 const items = [makeRow(0, { outputBytes: 2_000_000 }), makeRow(1, { outputBytes: 10 })];
341 const page = selectRecentTimelineWindow({
342 items,
343 snapshotSequence: 1,
344 policy: { maxItems: 50, maxEncodedBytes: 1_024 },
345 });
346 expect(page.items).toHaveLength(1);
347 expect(page.items[0]?.sourceItemId).toBe("item-1");
348 expect(page.hasMoreHistory).toBe(true);
349
350 const older = selectHistoryPageFromCursor({
351 items,
352 cursor: page.nextCursor!,
353 snapshotSequence: 1,
354 policy: { maxItems: 50, maxEncodedBytes: 1_024 },
355 });
356 expect(older.items).toHaveLength(1);
357 expect(older.items[0]?.sourceItemId).toBe("item-0");
358 expect(older.hasMoreHistory).toBe(false);
359 expect(older.nextCursor).toBeNull();
360 });
361
362 it("recovers identity-miss cursors when position is past a shrunken timeline", () => {
363 const items = Array.from({ length: 5 }, (_, index) => makeRow(index));
364 const cursor = encodeThreadHistoryCursor({
365 snapshotSequence: 3,
366 sourceThreadId: THREAD,
367 sourceItemId: TurnItemId.make("item-deleted-long-ago"),
368 position: 40,
369 });
370 const page = selectHistoryPageFromCursor({
371 items,
372 cursor,
373 snapshotSequence: 3,
374 policy: { maxItems: 2, maxEncodedBytes: 10_000_000 },
375 });
376 // Clamp exclusive end to items.length and page backward so the client can
377 // dedupe and advance rather than permanently 400 on a dead cursor.
378 expect(page.items.map((row) => row.sourceItemId)).toEqual(["item-3", "item-4"]);
379 expect(page.hasMoreHistory).toBe(true);
380 expect(page.nextCursor).not.toBeNull();
381 });
382
383 it("keeps cursor resolution stable when newer items append", () => {
384 const initial = Array.from({ length: 20 }, (_, index) => makeRow(index));
385 const recent = selectRecentTimelineWindow({
386 items: initial,
387 snapshotSequence: 2,
388 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
389 });
390 const cursor = recent.nextCursor!;
391 const grown = [...initial, makeRow(20), makeRow(21)];
392 const older = selectHistoryPageFromCursor({
393 items: grown,
394 cursor,
395 snapshotSequence: 5,
396 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
397 });
398 expect(older.items.map((row) => row.sourceItemId)).toEqual([
399 "item-10",
400 "item-11",
401 "item-12",
402 "item-13",
403 "item-14",
404 ]);
405 expect(older.hasMoreHistory).toBe(true);
406 });
407
408 it("builds a bounded projection that preserves non-timeline control state", () => {
409 const full = makeProjection(Array.from({ length: 40 }, (_, index) => makeRow(index)));
410 const bounded = buildBoundedThreadProjection({
411 projection: full,
412 snapshotSequence: 7,
413 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
414 });
415 expect(bounded.projection.runs).toEqual(full.runs);
416 expect(bounded.projection.runtimeRequests).toEqual(full.runtimeRequests);
417 expect(bounded.projection.attempts).toEqual(full.attempts);
418 expect(bounded.projection.visibleTurnItems).toHaveLength(5);
419 expect(bounded.projection.turnItems).toHaveLength(5);
420 expect(bounded.hasMoreHistory).toBe(true);
421 expect(bounded.historyCursor).not.toBeNull();
422 expect(bounded.projection.visibleTurnItems.map((row) => row.position)).toEqual([0, 1, 2, 3, 4]);
423 // Watermark comes from the full projection, not the trimmed window.
424 expect(bounded.latestLocalTurnOrdinal).toBe(40);
425 expect(computeLatestLocalTurnOrdinal(full.turnItems)).toBe(40);
426 });
427
428 it("preserves linked control rows as one coherent graph", () => {
429 const base = makeProjection(Array.from({ length: 40 }, (_, index) => makeRow(index)));
430 const linked = {
431 ...base,
432 runs: [{ id: "run-linked", threadId: THREAD }],
433 attempts: [{ id: "attempt-linked", runId: "run-linked" }],
434 nodes: [{ id: "node-linked", runId: "run-linked", attemptId: "attempt-linked" }],
435 providerThreads: [{ id: "provider-thread-linked", runId: "run-linked" }],
436 providerTurns: [
437 {
438 id: "provider-turn-linked",
439 providerThreadId: "provider-thread-linked",
440 nodeId: "node-linked",
441 },
442 ],
443 checkpointScopes: [{ id: "scope-linked", runId: "run-linked" }],
444 checkpoints: [{ id: "checkpoint-linked", scopeId: "scope-linked" }],
445 } as unknown as typeof base;
446
447 const bounded = buildBoundedThreadProjection({
448 projection: linked,
449 snapshotSequence: 7,
450 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
451 });
452
453 expect(bounded.projection.runs).toEqual(linked.runs);
454 expect(bounded.projection.attempts).toEqual(linked.attempts);
455 expect(bounded.projection.nodes).toEqual(linked.nodes);
456 expect(bounded.projection.providerThreads).toEqual(linked.providerThreads);
457 expect(bounded.projection.providerTurns).toEqual(linked.providerTurns);
458 expect(bounded.projection.checkpointScopes).toEqual(linked.checkpointScopes);
459 expect(bounded.projection.checkpoints).toEqual(linked.checkpoints);
460 });
461
462 it("carries a full-projection watermark when the bounded window is inherited-only", () => {
463 const localOlder = makeRow(0);
464 const localMid = makeRow(1);
465 const inherited = {
466 ...makeRow(2),
467 visibility: "inherited" as const,
468 sourceThreadId: ThreadId.make("parent-thread"),
469 item: {
470 ...makeRow(2).item,
471 threadId: ThreadId.make("parent-thread"),
472 },
473 };
474 // Full projection still has local turnItems even if the recent window is
475 // only the inherited row (e.g. after aggressive byte budget on a large
476 // local item that is not selected into the recent page). Use a synthetic
477 // projection with local turnItems and inherited-only visible window.
478 const full = makeProjection([localOlder, localMid, inherited]);
479 // Force an inherited-only visible window by rebuilding with only inherited
480 // visible rows while retaining full local turnItems for the watermark.
481 const inheritedOnly = {
482 ...full,
483 visibleTurnItems: [inherited],
484 turnItems: [localOlder.item, localMid.item],
485 };
486 const bounded = buildBoundedThreadProjection({
487 projection: inheritedOnly,
488 snapshotSequence: 2,
489 policy: { maxItems: 1, maxEncodedBytes: 10_000_000 },
490 });
491 expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([
492 String(inherited.sourceItemId),
493 ]);
494 expect(bounded.latestLocalTurnOrdinal).toBe(2);
495 expect(computeLatestLocalTurnOrdinal([])).toBeNull();
496 });
497
498 it("retains an out-of-window run_interrupt_request needed by a visible result", () => {
499 // Older request sits outside a 1-row recent window; result is visible alone.
500 const request = interruptRow(0, "run_interrupt_request");
501 const filler = makeRow(1);
502 const result = interruptRow(2, "run_interrupt_result");
503 const full = makeProjection([request, filler, result]);
504 const bounded = buildBoundedThreadProjection({
505 projection: full,
506 snapshotSequence: 3,
507 policy: { maxItems: 1, maxEncodedBytes: 10_000_000 },
508 });
509
510 expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([
511 "interrupt-res",
512 ]);
513 expect(bounded.projection.turnItems.map((item) => String(item.id)).sort()).toEqual([
514 "interrupt-req",
515 "interrupt-res",
516 ]);
517 expect(bounded.hasMoreHistory).toBe(true);
518 });
519
520 it("retains all run_interrupt_request items even when no result is in the initial window", () => {
521 // Cold open: recent window is only filler. Request and result both live on
522 // older pages, but the request must still ride in turnItems so a later
523 // history page can introduce the result safely under live attempt updates.
524 const request = interruptRow(0, "run_interrupt_request");
525 const result = interruptRow(1, "run_interrupt_result");
526 const recent = makeRow(2);
527 const full = makeProjection([request, result, recent]);
528 const bounded = buildBoundedThreadProjection({
529 projection: full,
530 snapshotSequence: 4,
531 policy: { maxItems: 1, maxEncodedBytes: 10_000_000 },
532 });
533
534 expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([
535 "item-2",
536 ]);
537 expect(bounded.projection.turnItems.map((item) => String(item.id)).sort()).toEqual([
538 "interrupt-req",
539 "item-2",
540 ]);
541 expect(bounded.projection.turnItems.some((item) => item.type === "run_interrupt_request")).toBe(
542 true,
543 );
544 expect(bounded.projection.turnItems.some((item) => item.type === "run_interrupt_result")).toBe(
545 false,
546 );
547 });
548
549 it("charges local row.item duplication when measuring bounded timeline bytes", () => {
550 const rows = Array.from({ length: 8 }, (_, index) => makeRow(index, { outputBytes: 2_000 }));
551 const full = makeProjection(rows);
552 const maxEncodedBytes = 12_000;
553 const bounded = buildBoundedThreadProjection({
554 projection: full,
555 snapshotSequence: 1,
556 policy: { maxItems: 50, maxEncodedBytes },
557 });
558
559 const contribution = boundedTimelineEncodedBytes({
560 visibleTurnItems: bounded.projection.visibleTurnItems,
561 turnItems: bounded.projection.turnItems,
562 });
563 expect(contribution).toBeLessThanOrEqual(maxEncodedBytes);
564 expect(bounded.projection.visibleTurnItems.length).toBeGreaterThan(0);
565 expect(bounded.projection.visibleTurnItems.length).toBeLessThan(rows.length);
566
567 // History pages only charge the projected row once (no turnItems copy).
568 const historyPage = selectRecentTimelineWindow({
569 items: rows,
570 snapshotSequence: 1,
571 policy: { maxItems: 50, maxEncodedBytes },
572 });
573 const historyBytes = historyPage.items.reduce(
574 (sum, row) => sum + projectedRowEncodedBytes(row),
575 0,
576 );
577 expect(historyBytes).toBeLessThanOrEqual(maxEncodedBytes);
578 expect(historyPage.items.length).toBeGreaterThan(bounded.projection.visibleTurnItems.length);
579
580 // Dual-cost for one local row exceeds the plain row cost.
581 const sample = rows[0]!;
582 expect(projectedRowBoundedSnapshotEncodedBytes(sample, THREAD)).toBeGreaterThan(
583 projectedRowEncodedBytes(sample),
584 );
585 });
586
587 it("allows a single pathological bounded row to exceed the configured cap", () => {
588 const huge = makeRow(0, { outputBytes: 50_000 });
589 const full = makeProjection([huge]);
590 const maxEncodedBytes = 1_000;
591 const bounded = buildBoundedThreadProjection({
592 projection: full,
593 snapshotSequence: 1,
594 policy: { maxItems: 10, maxEncodedBytes },
595 });
596 expect(bounded.projection.visibleTurnItems).toHaveLength(1);
597 const contribution = boundedTimelineEncodedBytes({
598 visibleTurnItems: bounded.projection.visibleTurnItems,
599 turnItems: bounded.projection.turnItems,
600 });
601 expect(contribution).toBeGreaterThan(maxEncodedBytes);
602 });
603
604 it("exposes conservative default page budgets", () => {
605 expect(THREAD_HISTORY_PAGE_POLICY.maxItems).toBeGreaterThanOrEqual(50);
606 expect(THREAD_HISTORY_PAGE_POLICY.maxItems).toBeLessThanOrEqual(100);
607 expect(THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes).toBe(1_048_576);
608 });
609
610 it("does not carry the full duplicated message table into a bounded snapshot", () => {
611 const projection = makeProjection(Array.from({ length: 120 }, (_, index) => makeRow(index)));
612 const full = {
613 ...projection,
614 messages: Array.from({ length: 2_000 }, (_, index) => ({
615 id: MessageId.make(`message-${index}`),
616 threadId: THREAD,
617 runId: RunId.make(`old-run-${index}`),
618 nodeId: null,
619 role: "assistant" as const,
620 text: "x".repeat(1_000),
621 attachments: [],
622 streaming: false,
623 createdBy: "agent" as const,
624 creationSource: "provider" as const,
625 createdAt: NOW,
626 updatedAt: NOW,
627 })),
628 } as typeof projection;
629
630 const bounded = buildBoundedThreadProjection({
631 projection: full,
632 snapshotSequence: 9,
633 });
634
635 expect(bounded.projection.messages).toEqual([]);
636 expect(Buffer.byteLength(JSON.stringify(bounded.projection), "utf8")).toBeLessThan(
637 THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes + 100_000,
638 );
639 });
640
641 it("omits historical control details that remain available from history items", () => {
642 const projection = makeProjection(Array.from({ length: 200 }, (_, index) => makeRow(index)));
643 const populated = {
644 ...projection,
645 plans: Array.from({ length: 120 }, (_, index) => ({
646 id: `plan-${index}`,
647 threadId: THREAD,
648 runId: null,
649 nodeId: `node-${index}`,
650 status: "completed",
651 kind: "proposed_plan",
652 markdown: "p".repeat(2_097_152),
653 })),
654 contextHandoffs: Array.from({ length: 120 }, (_, index) => ({
655 id: `handoff-${index}`,
656 threadId: THREAD,
657 targetRunId: "run-1",
658 fromProviderThreadIds: [],
659 toProviderThreadId: "provider-thread-1",
660 coveredRunOrdinals: { from: 1, to: 1 },
661 strategy: "manual_context",
662 status: "superseded",
663 summaryMessageId: null,
664 summaryText: "h".repeat(2_097_152),
665 createdByProviderInstanceId: null,
666 createdAt: NOW,
667 updatedAt: NOW,
668 })),
669 } as unknown as typeof projection;
670
671 const bounded = buildBoundedThreadProjection({
672 projection: projectThreadProjectionForWire(populated),
673 snapshotSequence: 9,
674 });
675 const serializedBytes = Buffer.byteLength(JSON.stringify(bounded.projection), "utf8");
676
677 expect(serializedBytes).toBeLessThanOrEqual(THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes);
678 expect(bounded.payloadBudgetExceeded).toBe(false);
679 expect(bounded.projection.plans).toEqual([]);
680 expect(bounded.projection.contextHandoffs).toEqual([]);
681 expect(populated.plans).toHaveLength(120);
682 expect(
683 populated.plans[0]?.kind === "proposed_plan" ? populated.plans[0].markdown.length : 0,
684 ).toBe(2_097_152);
685 });
686
687 it("preserves oversized actionable state and reports the budget exception", () => {
688 const projection = makeProjection([makeRow(0)]);
689 const actionable = {
690 ...projection,
691 plans: [
692 {
693 id: "plan-actionable",
694 threadId: THREAD,
695 runId: null,
696 nodeId: "node-actionable",
697 status: "active",
698 kind: "proposed_plan",
699 markdown: "a".repeat(2_097_152),
700 },
701 ],
702 } as unknown as typeof projection;
703
704 const bounded = buildBoundedThreadProjection({ projection: actionable, snapshotSequence: 9 });
705
706 expect(bounded.payloadBudgetExceeded).toBe(true);
707 expect(bounded.projection.plans[0]).toEqual(actionable.plans[0]);
708 expect(bounded.projection.visibleTurnItems).toEqual(actionable.visibleTurnItems);
709 });
710
711 it("keeps paged historical plan detail in the turn item and only status in its artifact", () => {
712 const detail = "Implement the historical plan exactly.\n".repeat(1_000);
713 const item = {
714 ...makeRow(0).item,
715 type: "proposed_plan",
716 planId: "plan-historical-visible",
717 markdown: detail,
718 streaming: false,
719 } as OrchestrationV2TurnItem;
720 const row = {
721 ...makeRow(0),
722 sourceItemId: item.id,
723 item,
724 } as OrchestrationV2ProjectedTurnItem;
725 const base = makeProjection([row]);
726 const projection = {
727 ...base,
728 plans: [
729 {
730 id: "plan-historical-visible",
731 threadId: THREAD,
732 runId: null,
733 nodeId: "node-historical-visible",
734 status: "completed",
735 kind: "proposed_plan",
736 markdown: detail,
737 },
738 ],
739 } as unknown as typeof base;
740
741 const bounded = buildBoundedThreadProjection({ projection, snapshotSequence: 9 });
742
743 expect(bounded.projection.visibleTurnItems[0]?.item).toMatchObject({
744 type: "proposed_plan",
745 markdown: detail,
746 });
747 expect(bounded.projection.plans[0]).toMatchObject({
748 status: "completed",
749 markdown: "",
750 detailInTurnItem: true,
751 });
752 expect(bounded.payloadBudgetExceeded).toBe(false);
753 });
754});