1import { ProjectId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
5import {
6 applyShellStreamEvent,
7 mergeShellSnapshotProjects,
8 reuseUnchangedThreadShells,
9} from "./shellReducer.ts";
10
11const repositoryIdentity = {
12 canonicalKey: "github.com/example/repo",
13 locator: {
14 source: "git-remote" as const,
15 remoteName: "origin",
16 remoteUrl: "https://github.com/example/repo.git",
17 },
18};
19
20const otherIdentity = {
21 canonicalKey: "github.com/example/other",
22 locator: {
23 source: "git-remote" as const,
24 remoteName: "origin",
25 remoteUrl: "https://github.com/example/other.git",
26 },
27};
28
29describe("applyShellStreamEvent", () => {
30 it("updates a thread in place without moving its siblings", () => {
31 const threads = ["a", "b", "c"].map((id) => ({ ...v2ThreadShell, id: ThreadId.make(id) }));
32 const updated = { ...threads[1]!, title: "Streaming" };
33 const next = applyShellStreamEvent(
34 { ...v2ShellSnapshot, threads },
35 {
36 kind: "thread.updated",
37 sequence: 1,
38 location: "active",
39 thread: updated,
40 },
41 );
42 expect(next.threads.map((thread) => thread.id)).toEqual(["a", "b", "c"]);
43 expect(next.threads[0]).toBe(threads[0]);
44 expect(next.threads[1]).toBe(updated);
45 expect(next.threads[2]).toBe(threads[2]);
46 });
47
48 it("keeps the thread list and object when an update changes nothing", () => {
49 const threads = ["a", "b"].map((id) => ({ ...v2ThreadShell, id: ThreadId.make(id) }));
50 const next = applyShellStreamEvent(
51 { ...v2ShellSnapshot, threads },
52 {
53 kind: "thread.updated",
54 sequence: 1,
55 location: "active",
56 thread: { ...threads[1]! },
57 },
58 );
59 expect(next.threads).toBe(threads);
60 expect(next.snapshotSequence).toBe(1);
61 });
62
63 it("ignores stale project updates without mutating the snapshot", () => {
64 const snapshotWithProject = {
65 ...v2ShellSnapshot,
66 snapshotSequence: 4,
67 projects: [v2Project],
68 };
69
70 for (const sequence of [3, 4]) {
71 const next = applyShellStreamEvent(snapshotWithProject, {
72 kind: "project.updated",
73 sequence,
74 project: { ...v2Project, title: "Stale Title" },
75 });
76
77 expect(next).toBe(snapshotWithProject);
78 expect(next.snapshotSequence).toBe(4);
79 expect(next.projects[0]?.title).toBe(v2Project.title);
80 }
81 });
82
83 it("applies project updates and removals", () => {
84 const updated = applyShellStreamEvent(v2ShellSnapshot, {
85 kind: "project.updated",
86 sequence: 1,
87 project: { ...v2Project, title: "Updated" },
88 });
89 expect(updated.projects[0]?.title).toBe("Updated");
90 expect(updated.snapshotSequence).toBe(1);
91
92 const removed = applyShellStreamEvent(updated, {
93 kind: "project.removed",
94 sequence: 2,
95 projectId: ProjectId.make(v2Project.id),
96 });
97 expect(removed.projects).toEqual([]);
98 });
99
100 it("keeps prior repositoryIdentity when a delta arrives with null identity", () => {
101 const withIdentity = applyShellStreamEvent(v2ShellSnapshot, {
102 kind: "project.updated",
103 sequence: 1,
104 project: { ...v2Project, repositoryIdentity },
105 });
106 expect(withIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
107
108 const withoutIdentity = applyShellStreamEvent(withIdentity, {
109 kind: "project.updated",
110 sequence: 2,
111 project: { ...v2Project, title: "Still same root", repositoryIdentity: null },
112 });
113 expect(withoutIdentity.projects[0]?.title).toBe("Still same root");
114 expect(withoutIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
115 });
116
117 it("does not keep prior repositoryIdentity after a workspace root move", () => {
118 const withIdentity = applyShellStreamEvent(v2ShellSnapshot, {
119 kind: "project.updated",
120 sequence: 1,
121 project: { ...v2Project, repositoryIdentity },
122 });
123
124 const moved = applyShellStreamEvent(withIdentity, {
125 kind: "project.updated",
126 sequence: 2,
127 project: {
128 ...v2Project,
129 workspaceRoot: "/tmp/other-root",
130 repositoryIdentity: null,
131 },
132 });
133 expect(moved.projects[0]?.workspaceRoot).toBe("/tmp/other-root");
134 expect(moved.projects[0]?.repositoryIdentity).toBeNull();
135 });
136
137 it("merges full snapshot projects without dropping known repositoryIdentity", () => {
138 const previous = {
139 ...v2ShellSnapshot,
140 projects: [{ ...v2Project, repositoryIdentity }],
141 };
142 const next = mergeShellSnapshotProjects(previous, {
143 ...v2ShellSnapshot,
144 snapshotSequence: 2,
145 projects: [{ ...v2Project, title: "Refreshed", repositoryIdentity: null }],
146 });
147 expect(next.projects[0]?.title).toBe("Refreshed");
148 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
149 });
150
151 it("authoritative lower-sequence reset replaces client-ahead structural state", () => {
152 const previous = {
153 ...v2ShellSnapshot,
154 snapshotSequence: 9,
155 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
156 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
157 };
158 const next = mergeShellSnapshotProjects(previous, {
159 ...v2ShellSnapshot,
160 snapshotSequence: 3,
161 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
162 threads: [{ ...v2ThreadShell, title: "Server thread" }],
163 });
164
165 expect(next.snapshotSequence).toBe(3);
166 expect(next.projects[0]?.title).toBe("Server reset");
167 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
168 expect(next.threads[0]?.title).toBe("Server thread");
169 });
170
171 it("client-ahead survives lower marked enrichment then still resets on unmarked authoritative", () => {
172 const previous = {
173 ...v2ShellSnapshot,
174 snapshotSequence: 20,
175 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
176 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
177 };
178 const serverSnapshot = {
179 ...v2ShellSnapshot,
180 snapshotSequence: 3,
181 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
182 threads: [{ ...v2ThreadShell, title: "Server thread" }],
183 };
184
185 // Lower-sequence marked enrichment is identity-only and must not replace structure.
186 const afterEnrichment = mergeShellSnapshotProjects(previous, serverSnapshot, {
187 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot],
188 });
189 expect(afterEnrichment.snapshotSequence).toBe(20);
190 expect(afterEnrichment.projects[0]?.title).toBe("Client ahead");
191 expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull();
192 expect(afterEnrichment.threads[0]?.title).toBe("Local-only thread");
193
194 // Unmarked authoritative at the same lower sequence still performs the reset.
195 const afterAuthoritative = mergeShellSnapshotProjects(afterEnrichment, serverSnapshot);
196 expect(afterAuthoritative.snapshotSequence).toBe(3);
197 expect(afterAuthoritative.projects[0]?.title).toBe("Server reset");
198 expect(afterAuthoritative.projects[0]?.repositoryIdentity).toBeNull();
199 expect(afterAuthoritative.threads[0]?.title).toBe("Server thread");
200 });
201
202 it("authoritative null retains identity then same-sequence marked enrichment clears it", () => {
203 const previous = {
204 ...v2ShellSnapshot,
205 snapshotSequence: 9,
206 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
207 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
208 };
209 const serverSnapshot = {
210 ...v2ShellSnapshot,
211 snapshotSequence: 3,
212 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
213 threads: [{ ...v2ThreadShell, title: "Server thread" }],
214 };
215
216 const afterAuthoritative = mergeShellSnapshotProjects(previous, serverSnapshot);
217 expect(afterAuthoritative.snapshotSequence).toBe(3);
218 expect(afterAuthoritative.projects[0]?.title).toBe("Server reset");
219 expect(afterAuthoritative.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
220 expect(afterAuthoritative.threads[0]?.title).toBe("Server thread");
221
222 const afterEnrichment = mergeShellSnapshotProjects(afterAuthoritative, serverSnapshot, {
223 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot],
224 });
225 expect(afterEnrichment.snapshotSequence).toBe(3);
226 expect(afterEnrichment.projects[0]?.title).toBe("Server reset");
227 expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull();
228 expect(afterEnrichment.threads[0]?.title).toBe("Server thread");
229 });
230
231 it("takes identity from a lower-sequence enrichment without reverting live thread state", () => {
232 const previous = {
233 ...v2ShellSnapshot,
234 snapshotSequence: 5,
235 projects: [{ ...v2Project, repositoryIdentity: null }],
236 threads: [{ ...v2ThreadShell, title: "Newest title" }],
237 };
238 const next = mergeShellSnapshotProjects(
239 previous,
240 {
241 ...v2ShellSnapshot,
242 snapshotSequence: 4,
243 projects: [{ ...v2Project, repositoryIdentity }],
244 threads: [{ ...v2ThreadShell, title: "Stale title" }],
245 },
246 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
247 );
248
249 expect(next.snapshotSequence).toBe(5);
250 expect(next.threads[0]?.title).toBe("Newest title");
251 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
252 });
253
254 it("treats higher-sequence compact enrichment as metadata-only", () => {
255 const previous = {
256 ...v2ShellSnapshot,
257 snapshotSequence: 5,
258 projects: [{ ...v2Project, repositoryIdentity: null }],
259 threads: [{ ...v2ThreadShell, title: "Newest title" }],
260 };
261 const next = mergeShellSnapshotProjects(
262 previous,
263 {
264 ...v2ShellSnapshot,
265 snapshotSequence: 8,
266 projects: [{ ...v2Project, repositoryIdentity }],
267 threads: [],
268 archivedThreads: [],
269 },
270 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
271 );
272
273 expect(next.snapshotSequence).toBe(5);
274 expect(next.threads[0]?.title).toBe("Newest title");
275 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
276 });
277
278 it("lower-sequence enrichment cannot resurrect empty structural state", () => {
279 const previous = {
280 ...v2ShellSnapshot,
281 snapshotSequence: 7,
282 projects: [],
283 threads: [],
284 archivedThreads: [],
285 };
286 const next = mergeShellSnapshotProjects(
287 previous,
288 {
289 ...v2ShellSnapshot,
290 snapshotSequence: 4,
291 projects: [{ ...v2Project, repositoryIdentity }],
292 threads: [v2ThreadShell],
293 },
294 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
295 );
296
297 expect(next.snapshotSequence).toBe(7);
298 expect(next.projects).toEqual([]);
299 expect(next.threads).toEqual([]);
300 });
301
302 it("resolved null clears identity only for the marked root", () => {
303 const otherProject = {
304 ...v2Project,
305 id: ProjectId.make("project-other"),
306 workspaceRoot: "/workspace/other",
307 repositoryIdentity: otherIdentity,
308 };
309 const previous = {
310 ...v2ShellSnapshot,
311 snapshotSequence: 5,
312 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
313 };
314 const next = mergeShellSnapshotProjects(
315 previous,
316 {
317 ...v2ShellSnapshot,
318 snapshotSequence: 4,
319 projects: [
320 { ...v2Project, repositoryIdentity: null },
321 { ...otherProject, repositoryIdentity: null },
322 ],
323 },
324 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
325 );
326
327 expect(next.snapshotSequence).toBe(5);
328 expect(next.projects).toHaveLength(2);
329 expect(next.projects[0]?.repositoryIdentity).toBeNull();
330 expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity);
331 });
332
333 it("unrelated unresolved roots retain identity in the same enrichment snapshot", () => {
334 const otherProject = {
335 ...v2Project,
336 id: ProjectId.make("project-other"),
337 workspaceRoot: "/workspace/other",
338 repositoryIdentity: otherIdentity,
339 };
340 const previous = {
341 ...v2ShellSnapshot,
342 snapshotSequence: 3,
343 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
344 threads: [{ ...v2ThreadShell, title: "Current thread" }],
345 };
346 const next = mergeShellSnapshotProjects(
347 previous,
348 {
349 ...v2ShellSnapshot,
350 snapshotSequence: 3,
351 projects: [
352 { ...v2Project, repositoryIdentity },
353 { ...otherProject, repositoryIdentity: null },
354 ],
355 threads: [{ ...v2ThreadShell, title: "Stale thread" }],
356 },
357 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
358 );
359
360 expect(next.threads[0]?.title).toBe("Current thread");
361 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
362 expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity);
363 });
364
365 it("clears identity for multiple roots resolved in one enrichment batch", () => {
366 const otherProject = {
367 ...v2Project,
368 id: ProjectId.make("project-other"),
369 workspaceRoot: "/workspace/other",
370 repositoryIdentity: otherIdentity,
371 };
372 const previous = {
373 ...v2ShellSnapshot,
374 snapshotSequence: 3,
375 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
376 };
377 const next = mergeShellSnapshotProjects(
378 previous,
379 {
380 ...v2ShellSnapshot,
381 snapshotSequence: 3,
382 projects: [
383 { ...v2Project, repositoryIdentity: null },
384 { ...otherProject, repositoryIdentity: null },
385 ],
386 },
387 {
388 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot, otherProject.workspaceRoot],
389 },
390 );
391
392 expect(next.projects.map((project) => project.repositoryIdentity)).toEqual([null, null]);
393 });
394
395 it("does not replace a live identity with one from an unmarked lower-sequence enrichment", () => {
396 const liveIdentity = {
397 canonicalKey: "github.com/example/live",
398 locator: {
399 source: "git-remote" as const,
400 remoteName: "origin",
401 remoteUrl: "https://github.com/example/live.git",
402 },
403 };
404 const staleIdentity = {
405 canonicalKey: "github.com/example/stale",
406 locator: {
407 source: "git-remote" as const,
408 remoteName: "origin",
409 remoteUrl: "https://github.com/example/stale.git",
410 },
411 };
412 const next = mergeShellSnapshotProjects(
413 {
414 ...v2ShellSnapshot,
415 snapshotSequence: 5,
416 projects: [{ ...v2Project, repositoryIdentity: liveIdentity }],
417 },
418 {
419 ...v2ShellSnapshot,
420 snapshotSequence: 4,
421 projects: [{ ...v2Project, repositoryIdentity: staleIdentity }],
422 },
423 { resolvedRepositoryIdentityRoots: [] },
424 );
425
426 expect(next.snapshotSequence).toBe(5);
427 expect(next.projects[0]?.repositoryIdentity).toEqual(liveIdentity);
428 });
429
430 it("keeps archive deltas out of the normal shell cache", () => {
431 const archived = applyShellStreamEvent(v2ShellSnapshot, {
432 kind: "thread.updated",
433 sequence: 3,
434 location: "archive",
435 thread: { ...v2ThreadShell, archivedAt: v2ThreadShell.updatedAt },
436 });
437 expect(archived.threads).toEqual([]);
438 expect(archived.archivedThreads).toEqual([]);
439
440 const active = applyShellStreamEvent(archived, {
441 kind: "thread.updated",
442 sequence: 4,
443 location: "active",
444 thread: v2ThreadShell,
445 });
446 expect(active.threads).toHaveLength(1);
447 expect(active.archivedThreads).toEqual([]);
448 });
449
450 it("removes a thread from either collection", () => {
451 const next = applyShellStreamEvent(v2ShellSnapshot, {
452 kind: "thread.removed",
453 sequence: 5,
454 location: "active",
455 threadId: ThreadId.make(v2ThreadShell.id),
456 });
457 expect(next.threads).toEqual([]);
458 expect(next.snapshotSequence).toBe(5);
459 });
460
461 it("leaves unknown future events unchanged", () => {
462 const next = applyShellStreamEvent(v2ShellSnapshot, {
463 kind: "future.event",
464 sequence: 99,
465 } as never);
466
467 expect(next).toBe(v2ShellSnapshot);
468 });
469});
470
471describe("reuseUnchangedThreadShells", () => {
472 const link: ThreadPullRequestLink = {
473 host: "github.com",
474 repository: "pingdotgg/t3code",
475 number: 3,
476 url: "https://github.com/pingdotgg/t3code/pull/3",
477 source: "agent",
478 linkedAt: "2026-06-20T00:00:00.000Z",
479 snapshot: null,
480 stack: null,
481 };
482 const linked = { ...v2ThreadShell, pullRequests: [link] };
483 const other = { ...v2ThreadShell, id: ThreadId.make("thread-other"), title: "Other" };
484
485 it("keeps the previous object for an unchanged row, even without its deferred links", () => {
486 const previous = { ...v2ShellSnapshot, threads: [linked, other] };
487 const { pullRequests: _links, ...withoutLinks } = linked;
488 const next = reuseUnchangedThreadShells(previous, {
489 ...v2ShellSnapshot,
490 snapshotSequence: 5,
491 threads: [withoutLinks, { ...other, title: "Renamed" }],
492 });
493 expect(next.snapshotSequence).toBe(5);
494 expect(next.threads[0]).toBe(linked);
495 expect(next.threads[1]).not.toBe(other);
496 expect(next.threads[1]?.title).toBe("Renamed");
497 });
498
499 it("returns the incoming snapshot when no row is unchanged", () => {
500 const incoming = { ...v2ShellSnapshot, threads: [{ ...other, title: "Renamed" }] };
501 expect(reuseUnchangedThreadShells({ ...v2ShellSnapshot, threads: [other] }, incoming)).toBe(
502 incoming,
503 );
504 });
505});