1import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4
5import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
6import {
7 applyHistoryPageMeta,
8 clearActiveHistoryLoading,
9 isActiveHistoryRequestCursor,
10 mergeOlderHistoryIntoProjection,
11 shouldShowLoadEarlierControl,
12} from "./threadHistoryMerge.ts";
13import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
14
15const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
16
17function row(index: number): OrchestrationV2ProjectedTurnItem {
18 const id = `item-${index}` as never;
19 return {
20 position: index,
21 visibility: "local",
22 sourceThreadId: v2ThreadId,
23 sourceItemId: id,
24 item: {
25 id,
26 type: "command_execution",
27 threadId: v2ThreadId,
28 runId: null,
29 nodeId: null,
30 providerThreadId: null,
31 providerTurnId: null,
32 nativeItemRef: null,
33 parentItemId: null,
34 ordinal: index + 1,
35 status: "completed",
36 title: `Command ${index}`,
37 input: `cmd-${index}`,
38 output: `out-${index}`,
39 exitCode: 0,
40 startedAt: NOW,
41 completedAt: NOW,
42 updatedAt: NOW,
43 },
44 };
45}
46
47describe("threadHistoryMerge", () => {
48 it("fills activity around partial messages in canonical order and preserves live edits", () => {
49 const initial = { ...v2Projection, turnItems: [row(4).item], visibleTurnItems: [row(4)] };
50 const preview = [row(1), row(3)];
51 const partial = mergeOlderHistoryIntoProjection(initial, preview);
52 const updated = { ...row(1), item: { ...row(1).item, title: "newer live title" } };
53 const live = {
54 ...partial,
55 visibleTurnItems: partial.visibleTurnItems.map((entry) =>
56 entry.sourceItemId === updated.sourceItemId ? updated : entry,
57 ),
58 turnItems: partial.turnItems.map((item) =>
59 item.id === updated.item.id ? updated.item : item,
60 ),
61 };
62 const complete = mergeOlderHistoryIntoProjection(live, [row(0), row(1), row(2), row(3)]);
63 expect(complete.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
64 "item-0",
65 "item-1",
66 "item-2",
67 "item-3",
68 "item-4",
69 ]);
70 expect(complete.visibleTurnItems[1]?.item).toBe(updated.item);
71 expect(complete.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3, 4]);
72 expect(new Set(complete.turnItems.map((item) => item.id)).size).toBe(5);
73 const hidden = {
74 ...partial,
75 visibleTurnItems: partial.visibleTurnItems.filter(
76 (entry) => entry.sourceItemId !== preview[0]!.sourceItemId,
77 ),
78 };
79 const hiddenComplete = mergeOlderHistoryIntoProjection(hidden, [
80 row(0),
81 row(1),
82 row(2),
83 row(3),
84 ]);
85 expect(hiddenComplete.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
86 "item-0",
87 "item-2",
88 "item-3",
89 "item-4",
90 ]);
91 });
92
93 it("prepends older rows and dedupes by source identity", () => {
94 const recent = [row(2), row(3)];
95 const projection = {
96 ...v2Projection,
97 turnItems: recent.map((entry) => entry.item),
98 visibleTurnItems: recent.map((entry, position) => ({ ...entry, position })),
99 };
100 const older = [row(0), row(1), row(2)];
101 const merged = mergeOlderHistoryIntoProjection(projection, older);
102 expect(merged.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
103 "item-0",
104 "item-1",
105 "item-2",
106 "item-3",
107 ]);
108 expect(merged.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3]);
109 expect(merged.turnItems.map((item) => item.id)).toEqual([
110 "item-2",
111 "item-3",
112 "item-0",
113 "item-1",
114 ]);
115 });
116
117 it("fills inherited activity without confusing identical item IDs from different source threads", () => {
118 const inherited = (index: number) => ({
119 ...row(index),
120 visibility: "inherited" as const,
121 sourceThreadId: "fork-source" as never,
122 });
123 const local = row(1);
124 const preview = { ...inherited(1), item: { ...row(1).item, title: "newer inherited title" } };
125 const projection = {
126 ...v2Projection,
127 turnItems: [local.item],
128 visibleTurnItems: [preview, local],
129 };
130 const merged = mergeOlderHistoryIntoProjection(projection, [inherited(0), inherited(1)]);
131 expect(
132 merged.visibleTurnItems.map((entry) => [entry.sourceThreadId, entry.sourceItemId]),
133 ).toEqual([
134 ["fork-source", "item-0"],
135 ["fork-source", "item-1"],
136 [v2ThreadId, "item-1"],
137 ]);
138 expect(merged.visibleTurnItems[1]?.item).toBe(preview.item);
139 expect(merged.turnItems).toEqual([local.item]);
140 expect(mergeOlderHistoryIntoProjection(merged, [inherited(0), inherited(1)])).toBe(merged);
141 });
142
143 it("retains live rows that arrived after the older page was fetched", () => {
144 const live = [row(5), row(6)];
145 const projection = {
146 ...v2Projection,
147 turnItems: live.map((entry) => entry.item),
148 visibleTurnItems: live.map((entry, position) => ({ ...entry, position })),
149 };
150 const older = [row(3), row(4)];
151 const merged = mergeOlderHistoryIntoProjection(projection, older);
152 expect(merged.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
153 "item-3",
154 "item-4",
155 "item-5",
156 "item-6",
157 ]);
158 });
159
160 it("does not resurrect a local item hidden while an older page was in flight", () => {
161 const stalePageRow = row(1);
162 const currentItem = {
163 ...stalePageRow.item,
164 output: "newer live output",
165 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"),
166 };
167 const projection = {
168 ...v2Projection,
169 turnItems: [currentItem],
170 visibleTurnItems: [],
171 };
172
173 const merged = mergeOlderHistoryIntoProjection(projection, [stalePageRow]);
174
175 expect(merged).toBe(projection);
176 expect(merged.turnItems).toEqual([currentItem]);
177 expect(merged.visibleTurnItems).toEqual([]);
178 });
179
180 it("marks history expanded after a successful page", () => {
181 const next = applyHistoryPageMeta(
182 {
183 historyCursor: "cursor-a",
184 hasMoreHistory: true,
185 loading: true,
186 error: null,
187 expanded: false,
188 latestLocalTurnOrdinal: 42,
189 },
190 { nextCursor: "cursor-b", hasMoreHistory: true },
191 );
192 expect(next).toEqual({
193 historyCursor: "cursor-b",
194 hasMoreHistory: true,
195 loading: false,
196 error: null,
197 expanded: true,
198 latestLocalTurnOrdinal: 42,
199 });
200 });
201
202 it("only applies history responses for the active request cursor", () => {
203 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: "cursor-a" })).toBe(true);
204 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: "cursor-b" })).toBe(false);
205 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: null })).toBe(false);
206 });
207
208 it("clears loading on interrupt only for the active request cursor", () => {
209 const loading = {
210 historyCursor: "cursor-a",
211 hasMoreHistory: true,
212 loading: true,
213 error: null,
214 expanded: false,
215 latestLocalTurnOrdinal: 10,
216 };
217 expect(clearActiveHistoryLoading("cursor-a", loading)).toEqual({
218 ...loading,
219 loading: false,
220 });
221 // Stale cursor must not mutate replacement progressive meta.
222 expect(
223 clearActiveHistoryLoading("cursor-a", { ...loading, historyCursor: "cursor-b" }),
224 ).toEqual({ ...loading, historyCursor: "cursor-b" });
225 // Already idle: identity-preserving no-op shape.
226 const idle = { ...loading, loading: false };
227 expect(clearActiveHistoryLoading("cursor-a", idle)).toBe(idle);
228 });
229
230 it("shows the load-earlier control when history remains or a local error is set", () => {
231 expect(
232 shouldShowLoadEarlierControl({
233 historyCursor: "c",
234 hasMoreHistory: true,
235 loading: false,
236 error: null,
237 expanded: false,
238 latestLocalTurnOrdinal: null,
239 }),
240 ).toBe(true);
241 expect(
242 shouldShowLoadEarlierControl({
243 historyCursor: null,
244 hasMoreHistory: false,
245 loading: false,
246 error: "Could not load earlier activity.",
247 expanded: true,
248 latestLocalTurnOrdinal: null,
249 }),
250 ).toBe(true);
251 expect(
252 shouldShowLoadEarlierControl({
253 historyCursor: null,
254 hasMoreHistory: false,
255 loading: false,
256 error: null,
257 expanded: false,
258 latestLocalTurnOrdinal: null,
259 }),
260 ).toBe(false);
261 });
262
263 it("retains merged older rows when a live turn-item update arrives", () => {
264 const recent = [row(2), row(3)];
265 const projection = {
266 ...v2Projection,
267 turnItems: recent.map((entry) => entry.item),
268 visibleTurnItems: recent.map((entry, position) => ({ ...entry, position })),
269 };
270 const merged = mergeOlderHistoryIntoProjection(projection, [row(0), row(1)]);
271 const liveItem = {
272 ...row(3).item,
273 output: "streamed",
274 };
275 const next = applyOrchestrationV2ProjectionEvent(merged, {
276 id: "event-live-item" as never,
277 type: "turn-item.updated",
278 threadId: v2ThreadId,
279 occurredAt: NOW,
280 payload: liveItem,
281 } as never);
282 expect(next?.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
283 "item-0",
284 "item-1",
285 "item-2",
286 "item-3",
287 ]);
288 expect(next?.visibleTurnItems[3]?.item).toEqual(liveItem);
289 });
290
291 it("keeps a history-page interrupt result visible when its request was retained at cold open", () => {
292 // Cold bounded window: only a recent filler. Request was retained in
293 // turnItems (not visible). Result arrives later via load-earlier merge.
294 const runId = "run-interrupt-cold" as never;
295 const nodeId = "node-interrupt-cold" as never;
296 const recent = row(5);
297 const requestItem = {
298 id: "item-interrupt-request" as never,
299 type: "run_interrupt_request" as const,
300 threadId: v2ThreadId,
301 runId,
302 nodeId,
303 providerThreadId: null,
304 providerTurnId: null,
305 nativeItemRef: null,
306 parentItemId: null,
307 ordinal: 1,
308 status: "completed" as const,
309 title: null,
310 message: "Stopping",
311 startedAt: NOW,
312 completedAt: NOW,
313 updatedAt: NOW,
314 };
315 const resultItem = {
316 id: "item-interrupt-result" as never,
317 type: "run_interrupt_result" as const,
318 threadId: v2ThreadId,
319 runId,
320 nodeId,
321 providerThreadId: null,
322 providerTurnId: null,
323 nativeItemRef: null,
324 parentItemId: null,
325 ordinal: 2,
326 status: "completed" as const,
327 title: null,
328 message: "Stopped",
329 startedAt: NOW,
330 completedAt: NOW,
331 updatedAt: NOW,
332 };
333 const resultRow = {
334 position: 0,
335 visibility: "local" as const,
336 sourceThreadId: v2ThreadId,
337 sourceItemId: resultItem.id,
338 item: resultItem,
339 };
340 const coldOpen = {
341 ...v2Projection,
342 runs: [
343 {
344 id: runId,
345 threadId: v2ThreadId,
346 ordinal: 1,
347 providerInstanceId: "codex" as never,
348 modelSelection: { instanceId: "codex" as never, model: "gpt-5" },
349 providerThreadId: null,
350 userMessageId: "message-1" as never,
351 rootNodeId: nodeId,
352 activeAttemptId: null,
353 status: "running" as const,
354 requestedAt: NOW,
355 startedAt: NOW,
356 completedAt: null,
357 checkpointId: null,
358 contextHandoffId: null,
359 },
360 ],
361 attempts: [
362 {
363 id: "attempt-1" as never,
364 runId,
365 attemptOrdinal: 1,
366 rootNodeId: nodeId,
367 providerInstanceId: "codex" as never,
368 providerThreadId: "provider-thread-1" as never,
369 providerTurnId: null,
370 reason: "initial" as const,
371 status: "running" as const,
372 startedAt: NOW,
373 completedAt: null,
374 },
375 ],
376 // Request retained from full projection; no result yet in the window.
377 turnItems: [requestItem, recent.item],
378 visibleTurnItems: [{ ...recent, position: 0 }],
379 };
380
381 const requestMerged = mergeOlderHistoryIntoProjection(coldOpen, [
382 {
383 position: 0,
384 visibility: "local",
385 sourceThreadId: v2ThreadId,
386 sourceItemId: requestItem.id,
387 item: requestItem,
388 },
389 ]);
390 expect(requestMerged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
391 "item-interrupt-request",
392 "item-5",
393 ]);
394
395 const rolledBackRequestMerged = mergeOlderHistoryIntoProjection(
396 {
397 ...coldOpen,
398 runs: coldOpen.runs.map((run) => ({
399 ...run,
400 status: "rolled_back" as const,
401 completedAt: NOW,
402 })),
403 },
404 [
405 {
406 position: 0,
407 visibility: "local",
408 sourceThreadId: v2ThreadId,
409 sourceItemId: requestItem.id,
410 item: requestItem,
411 },
412 ],
413 );
414 expect(
415 rolledBackRequestMerged.visibleTurnItems.map((entry) => String(entry.sourceItemId)),
416 ).toEqual(["item-5"]);
417
418 const merged = mergeOlderHistoryIntoProjection(coldOpen, [resultRow]);
419 expect(merged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
420 "item-interrupt-result",
421 "item-5",
422 ]);
423 expect(merged.turnItems.some((item) => item.type === "run_interrupt_request")).toBe(true);
424 expect(merged.turnItems.some((item) => item.type === "run_interrupt_result")).toBe(true);
425
426 const attemptSuperseded = applyOrchestrationV2ProjectionEvent(
427 merged,
428 {
429 id: "event-attempt-superseded" as never,
430 type: "run-attempt.updated",
431 threadId: v2ThreadId,
432 runId,
433 occurredAt: NOW,
434 payload: {
435 id: "attempt-1" as never,
436 runId,
437 attemptOrdinal: 1,
438 rootNodeId: nodeId,
439 providerInstanceId: "codex" as never,
440 providerThreadId: "provider-thread-1" as never,
441 providerTurnId: null,
442 reason: "initial",
443 status: "superseded",
444 startedAt: NOW,
445 completedAt: NOW,
446 },
447 } as never,
448 { partialTimeline: true },
449 );
450
451 expect(attemptSuperseded?.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
452 "item-interrupt-result",
453 "item-5",
454 ]);
455
456 // Without the cold-open retained request, the same update would hide the result.
457 const withoutRequest = {
458 ...merged,
459 turnItems: merged.turnItems.filter((item) => item.type !== "run_interrupt_request"),
460 };
461 const hidden = applyOrchestrationV2ProjectionEvent(
462 withoutRequest,
463 {
464 id: "event-attempt-superseded-missing" as never,
465 type: "run-attempt.updated",
466 threadId: v2ThreadId,
467 runId,
468 occurredAt: NOW,
469 payload: {
470 id: "attempt-1" as never,
471 runId,
472 attemptOrdinal: 1,
473 rootNodeId: nodeId,
474 providerInstanceId: "codex" as never,
475 providerThreadId: "provider-thread-1" as never,
476 providerTurnId: null,
477 reason: "initial",
478 status: "superseded",
479 startedAt: NOW,
480 completedAt: NOW,
481 },
482 } as never,
483 { partialTimeline: true },
484 );
485 expect(hidden?.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual(["item-5"]);
486 });
487});