Skip to content
Source
Sign inSign up

shellReducer.test.ts

432 lines15.4 KB

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 applyShellStreamEvents,
8 type ShellStreamEvent,
9 mergeShellSnapshotProjects,
10 reuseUnchangedThreadShells,
11} from "./shellReducer.ts";
12​
13const repositoryIdentity = {
14 canonicalKey: "github.com/example/repo",
15 locator: {
16 source: "git-remote" as const,
17 remoteName: "origin",
18 remoteUrl: "https://github.com/example/repo.git",
19 },
20};
21​
22const otherIdentity = {
23 canonicalKey: "github.com/example/other",
24 locator: {
25 source: "git-remote" as const,
26 remoteName: "origin",
27 remoteUrl: "https://github.com/example/other.git",
28 },
29};
30​
31describe("applyShellStreamEvent", () => {
32 it("ignores stale project updates without mutating the snapshot", () => {
33 const snapshotWithProject = {
34 ...v2ShellSnapshot,
35 snapshotSequence: 4,
36 projects: [v2Project],
37 };
38​
39 for (const sequence of [3, 4]) {
40 const next = applyShellStreamEvent(snapshotWithProject, {
41 kind: "project.updated",
42 sequence,
43 project: { ...v2Project, title: "Stale Title" },
44 });
45​
46 expect(next).toBe(snapshotWithProject);
47 expect(next.snapshotSequence).toBe(4);
48 expect(next.projects[0]?.title).toBe(v2Project.title);
49 }
50 });
51​
52 it("keeps prior repositoryIdentity when a delta arrives with null identity", () => {
53 const withIdentity = applyShellStreamEvent(v2ShellSnapshot, {
54 kind: "project.updated",
55 sequence: 1,
56 project: { ...v2Project, repositoryIdentity },
57 });
58 expect(withIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
59​
60 const withoutIdentity = applyShellStreamEvent(withIdentity, {
61 kind: "project.updated",
62 sequence: 2,
63 project: { ...v2Project, title: "Still same root", repositoryIdentity: null },
64 });
65 expect(withoutIdentity.projects[0]?.title).toBe("Still same root");
66 expect(withoutIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
67 });
68​
69 it("does not keep prior repositoryIdentity after a workspace root move", () => {
70 const withIdentity = applyShellStreamEvent(v2ShellSnapshot, {
71 kind: "project.updated",
72 sequence: 1,
73 project: { ...v2Project, repositoryIdentity },
74 });
75​
76 const moved = applyShellStreamEvent(withIdentity, {
77 kind: "project.updated",
78 sequence: 2,
79 project: {
80 ...v2Project,
81 workspaceRoot: "/tmp/other-root",
82 repositoryIdentity: null,
83 },
84 });
85 expect(moved.projects[0]?.workspaceRoot).toBe("/tmp/other-root");
86 expect(moved.projects[0]?.repositoryIdentity).toBeNull();
87 });
88​
89 it("authoritative lower-sequence reset replaces client-ahead structural state", () => {
90 const unchanged = { ...v2Project, id: ProjectId.make("project-unchanged"), repositoryIdentity };
91 const previous = {
92 ...v2ShellSnapshot,
93 snapshotSequence: 9,
94 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }, unchanged],
95 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
96 };
97 const next = mergeShellSnapshotProjects(previous, {
98 ...v2ShellSnapshot,
99 snapshotSequence: 3,
100 projects: [
101 { ...v2Project, title: "Server reset", repositoryIdentity: null },
102 { ...unchanged, scripts: [...unchanged.scripts] },
103 ],
104 threads: [{ ...v2ThreadShell, title: "Server thread" }],
105 });
106​
107 expect(next.snapshotSequence).toBe(3);
108 expect(next.projects[0]?.title).toBe("Server reset");
109 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
110 expect(next.projects[1]).toBe(unchanged);
111 expect(
112 mergeShellSnapshotProjects(next, { ...v2ShellSnapshot, projects: [...next.projects] })
113 .projects,
114 ).toBe(next.projects);
115 expect(next.threads[0]?.title).toBe("Server thread");
116 });
117​
118 it("client-ahead survives lower marked enrichment then still resets on unmarked authoritative", () => {
119 const previous = {
120 ...v2ShellSnapshot,
121 snapshotSequence: 20,
122 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
123 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
124 };
125 const serverSnapshot = {
126 ...v2ShellSnapshot,
127 snapshotSequence: 3,
128 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
129 threads: [{ ...v2ThreadShell, title: "Server thread" }],
130 };
131​
132 const afterEnrichment = mergeShellSnapshotProjects(previous, serverSnapshot, {
133 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot],
134 });
135 expect(afterEnrichment.snapshotSequence).toBe(20);
136 expect(afterEnrichment.projects[0]?.title).toBe("Client ahead");
137 expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull();
138 expect(afterEnrichment.threads[0]?.title).toBe("Local-only thread");
139​
140 const afterAuthoritative = mergeShellSnapshotProjects(afterEnrichment, serverSnapshot);
141 expect(afterAuthoritative.snapshotSequence).toBe(3);
142 expect(afterAuthoritative.projects[0]?.title).toBe("Server reset");
143 expect(afterAuthoritative.projects[0]?.repositoryIdentity).toBeNull();
144 expect(afterAuthoritative.threads[0]?.title).toBe("Server thread");
145 });
146​
147 it("takes identity from a lower-sequence enrichment without reverting live thread state", () => {
148 const previous = {
149 ...v2ShellSnapshot,
150 snapshotSequence: 5,
151 projects: [{ ...v2Project, repositoryIdentity: null }],
152 threads: [{ ...v2ThreadShell, title: "Newest title" }],
153 };
154 const next = mergeShellSnapshotProjects(
155 previous,
156 {
157 ...v2ShellSnapshot,
158 snapshotSequence: 4,
159 projects: [{ ...v2Project, repositoryIdentity }],
160 threads: [{ ...v2ThreadShell, title: "Stale title" }],
161 },
162 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
163 );
164​
165 expect(next.snapshotSequence).toBe(5);
166 expect(next.threads[0]?.title).toBe("Newest title");
167 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
168 });
169​
170 it("lower-sequence enrichment cannot resurrect empty structural state", () => {
171 const previous = {
172 ...v2ShellSnapshot,
173 snapshotSequence: 7,
174 projects: [],
175 threads: [],
176 archivedThreads: [],
177 };
178 const next = mergeShellSnapshotProjects(
179 previous,
180 {
181 ...v2ShellSnapshot,
182 snapshotSequence: 4,
183 projects: [{ ...v2Project, repositoryIdentity }],
184 threads: [v2ThreadShell],
185 },
186 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
187 );
188​
189 expect(next.snapshotSequence).toBe(7);
190 expect(next.projects).toEqual([]);
191 expect(next.threads).toEqual([]);
192 });
193​
194 it("resolved null clears identity only for the marked root", () => {
195 const otherProject = {
196 ...v2Project,
197 id: ProjectId.make("project-other"),
198 workspaceRoot: "/workspace/other",
199 repositoryIdentity: otherIdentity,
200 };
201 const previous = {
202 ...v2ShellSnapshot,
203 snapshotSequence: 5,
204 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
205 };
206 const next = mergeShellSnapshotProjects(
207 previous,
208 {
209 ...v2ShellSnapshot,
210 snapshotSequence: 4,
211 projects: [
212 { ...v2Project, repositoryIdentity: null },
213 { ...otherProject, repositoryIdentity: null },
214 ],
215 },
216 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
217 );
218​
219 expect(next.snapshotSequence).toBe(5);
220 expect(next.projects).toHaveLength(2);
221 expect(next.projects[0]?.repositoryIdentity).toBeNull();
222 expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity);
223 });
224​
225 it("does not replace a live identity with one from an unmarked lower-sequence enrichment", () => {
226 const liveIdentity = {
227 canonicalKey: "github.com/example/live",
228 locator: {
229 source: "git-remote" as const,
230 remoteName: "origin",
231 remoteUrl: "https://github.com/example/live.git",
232 },
233 };
234 const staleIdentity = {
235 canonicalKey: "github.com/example/stale",
236 locator: {
237 source: "git-remote" as const,
238 remoteName: "origin",
239 remoteUrl: "https://github.com/example/stale.git",
240 },
241 };
242 const next = mergeShellSnapshotProjects(
243 {
244 ...v2ShellSnapshot,
245 snapshotSequence: 5,
246 projects: [{ ...v2Project, repositoryIdentity: liveIdentity }],
247 },
248 {
249 ...v2ShellSnapshot,
250 snapshotSequence: 4,
251 projects: [{ ...v2Project, repositoryIdentity: staleIdentity }],
252 },
253 { resolvedRepositoryIdentityRoots: [] },
254 );
255​
256 expect(next.snapshotSequence).toBe(5);
257 expect(next.projects[0]?.repositoryIdentity).toEqual(liveIdentity);
258 });
259​
260 it("keeps archive deltas out of the normal shell cache", () => {
261 const archived = applyShellStreamEvent(v2ShellSnapshot, {
262 kind: "thread.updated",
263 sequence: 3,
264 location: "archive",
265 thread: { ...v2ThreadShell, archivedAt: v2ThreadShell.updatedAt },
266 });
267 expect(archived.threads).toEqual([]);
268 expect(archived.archivedThreads).toEqual([]);
269​
270 const active = applyShellStreamEvent(archived, {
271 kind: "thread.updated",
272 sequence: 4,
273 location: "active",
274 thread: v2ThreadShell,
275 });
276 expect(active.threads).toHaveLength(1);
277 expect(active.archivedThreads).toEqual([]);
278 });
279​
280 it("leaves unknown future events unchanged", () => {
281 const next = applyShellStreamEvent(v2ShellSnapshot, {
282 kind: "future.event",
283 sequence: 99,
284 } as never);
285​
286 expect(next).toBe(v2ShellSnapshot);
287 });
288});
289​
290describe("reuseUnchangedThreadShells", () => {
291 const link: ThreadPullRequestLink = {
292 host: "github.com",
293 repository: "pingdotgg/t3code",
294 number: 3,
295 url: "https://github.com/pingdotgg/t3code/pull/3",
296 source: "agent",
297 linkedAt: "2026-06-20T00:00:00.000Z",
298 snapshot: null,
299 stack: null,
300 };
301 const linked = { ...v2ThreadShell, pullRequests: [link] };
302 const other = { ...v2ThreadShell, id: ThreadId.make("thread-other"), title: "Other" };
303​
304 it("keeps the previous object for an unchanged row, even without its deferred links", () => {
305 const previous = { ...v2ShellSnapshot, threads: [linked, other] };
306 const { pullRequests: _links, ...withoutLinks } = linked;
307 const next = reuseUnchangedThreadShells(previous, {
308 ...v2ShellSnapshot,
309 snapshotSequence: 5,
310 threads: [withoutLinks, { ...other, title: "Renamed" }],
311 });
312 expect(next.snapshotSequence).toBe(5);
313 expect(next.threads[0]).toBe(linked);
314 expect(next.threads[1]).not.toBe(other);
315 expect(next.threads[1]?.title).toBe("Renamed");
316 });
317​
318 it("retains deferred links when the only row changes status", () => {
319 const { pullRequests: _links, ...withoutLinks } = linked;
320 const next = reuseUnchangedThreadShells(
321 { ...v2ShellSnapshot, threads: [linked] },
322 {
323 ...v2ShellSnapshot,
324 snapshotSequence: 5,
325 threads: [{ ...withoutLinks, status: "running" }],
326 },
327 );
328 expect(next.snapshotSequence).toBe(5);
329 expect(next.threads[0]?.status).toBe("running");
330 expect(next.threads[0]?.pullRequests).toBe(linked.pullRequests);
331 });
332​
333 it("keeps an explicit link removal when metadata also changes", () => {
334 const updated = { ...linked, title: "Renamed", pullRequests: [] };
335 const incoming = { ...v2ShellSnapshot, threads: [updated] };
336 const next = reuseUnchangedThreadShells({ ...v2ShellSnapshot, threads: [linked] }, incoming);
337 expect(next).toBe(incoming);
338 expect(next.threads[0]?.pullRequests).toEqual([]);
339 });
340});
341​
342describe("applyShellStreamEvents", () => {
343 const row = (id: string) => ({ ...v2ThreadShell, id: ThreadId.make(id) });
344 const a = row("a");
345 const b = row("b");
346 const unchanged = row("unchanged");
347 const archived = row("archived");
348 const oldArchive = row("old-archive");
349 const added = row("new");
350 const update = (
351 sequence: number,
352 thread: typeof v2ThreadShell,
353 location: "active" | "archive" = "active",
354 ): ShellStreamEvent => ({ kind: "thread.updated", sequence, location, thread });
355 const remove = (sequence: number, thread: typeof v2ThreadShell): ShellStreamEvent => ({
356 kind: "thread.removed",
357 sequence,
358 location: "active",
359 threadId: thread.id,
360 });
361​
362 it("matches sequential mixed deltas, including remove/reinsert order and stale events", () => {
363 const otherProject = { ...v2Project, id: ProjectId.make("other-project") };
364 const seed = {
365 ...v2ShellSnapshot,
366 snapshotSequence: 4,
367 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
368 threads: [a, b, unchanged],
369 archivedThreads: [archived, oldArchive],
370 };
371 const events: Array<ShellStreamEvent> = [
372 update(5, { ...b, title: "Renamed" }),
373 remove(5, a),
374 remove(6, a),
375 update(7, a),
376 update(8, b, "archive"),
377 update(9, archived),
378 update(10, oldArchive, "archive"),
379 update(11, added),
380 remove(12, { ...a, id: ThreadId.make("missing") }),
381 remove(11, added),
382 remove(13, added),
383 update(14, added),
384 update(15, { ...archived }),
385 { kind: "project.updated", sequence: 16, project: { ...v2Project, title: "Updated" } },
386 { kind: "project.updated", sequence: 17, project: { ...v2Project, workspaceRoot: "/moved" } },
387 { kind: "project.removed", sequence: 18, projectId: v2Project.id },
388 { kind: "project.updated", sequence: 19, project: v2Project },
389 ];
390 const sequential = events.reduce(applyShellStreamEvent, seed);
391 const next = applyShellStreamEvents(seed, events);
392 expect(next).toEqual(sequential);
393 expect(next.threads.map((row) => row.id)).toEqual([unchanged.id, a.id, archived.id, added.id]);
394 expect(next.threads[0]).toBe(unchanged);
395 expect(next.threads[2]).toBe(archived);
396 expect(next.projects[0]).toBe(otherProject);
397 expect(next.archivedThreads).toEqual([]);
398 expect(seed.threads).toEqual([a, b, unchanged]);
399 expect(seed.archivedThreads).toEqual([archived, oldArchive]);
400 // Identity retention must use the latest project in this batch.
401 expect(applyShellStreamEvents(seed, events.slice(0, 14)).projects[0]?.repositoryIdentity).toBe(
402 repositoryIdentity,
403 );
404 expect(
405 applyShellStreamEvents(seed, events.slice(0, 15)).projects[0]?.repositoryIdentity,
406 ).toBeNull();
407 });
408​
409 it("keeps collection identities for equal rows and rejects stale deltas without indexing", () => {
410 const seed = { ...v2ShellSnapshot, threads: [a, b], snapshotSequence: 4 };
411 const next = applyShellStreamEvents(seed, [update(5, { ...a }), update(6, { ...b })]);
412 expect(next).toEqual(
413 [update(5, { ...a }), update(6, { ...b })].reduce(applyShellStreamEvent, seed),
414 );
415 expect(next.threads).toBe(seed.threads);
416 expect(next.projects).toBe(seed.projects);
417 expect(next.archivedThreads).toBe(seed.archivedThreads);
418 expect(next.snapshotSequence).toBe(6);
419 expect(applyShellStreamEvents(seed, [remove(3, a), update(4, b)])).toBe(seed);
420 expect(applyShellStreamEvents(seed, [])).toBe(seed);
421 });
422​
423 it("does not advance the cursor for an unknown future event", () => {
424 const events = [
425 { kind: "future.event", sequence: 99 } as never,
426 update(1, { ...a, title: "Changed" }),
427 ];
428 expect(applyShellStreamEvents(v2ShellSnapshot, events)).toEqual(
429 events.reduce(applyShellStreamEvent, v2ShellSnapshot),
430 );
431 });
432});