1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts";
4
5import {
6 applyTerminalAttachStreamEvent,
7 applyTerminalMetadataStreamEvent,
8 combineTerminalSessionState,
9 DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
10 EMPTY_TERMINAL_BUFFER_STATE,
11 INITIAL_TERMINAL_OUTPUT_CURSOR,
12 nextTerminalAttachSeedState,
13 readTerminalOutputUpdate,
14 selectRunningSubprocessTerminalIds,
15 terminalOutputText,
16} from "./terminalSession.ts";
17
18const TARGET = {
19 environmentId: EnvironmentId.make("env-local"),
20 threadId: ThreadId.make("thread-1"),
21 terminalId: "term-1",
22} as const;
23
24const BASE_SNAPSHOT: TerminalSessionSnapshot = {
25 threadId: TARGET.threadId,
26 terminalId: TARGET.terminalId,
27 cwd: "/repo",
28 worktreePath: null,
29 status: "running",
30 pid: 123,
31 history: "hello",
32 exitCode: null,
33 exitSignal: null,
34 label: "Terminal 1",
35 updatedAt: "2026-04-01T00:00:00.000Z",
36};
37
38describe("terminal session reducers", () => {
39 it("prefers live attach status over stale metadata after the attach stream starts", () => {
40 const summary = applyTerminalMetadataStreamEvent([], {
41 type: "snapshot",
42 terminals: [
43 {
44 threadId: BASE_SNAPSHOT.threadId,
45 terminalId: BASE_SNAPSHOT.terminalId,
46 cwd: BASE_SNAPSHOT.cwd,
47 worktreePath: BASE_SNAPSHOT.worktreePath,
48 status: "running",
49 pid: BASE_SNAPSHOT.pid,
50 exitCode: BASE_SNAPSHOT.exitCode,
51 exitSignal: BASE_SNAPSHOT.exitSignal,
52 updatedAt: BASE_SNAPSHOT.updatedAt,
53 hasRunningSubprocess: false,
54 label: BASE_SNAPSHOT.label,
55 },
56 ],
57 })[0]!;
58 const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
59 type: "error",
60 threadId: TARGET.threadId,
61 terminalId: TARGET.terminalId,
62 message: "Terminal disconnected.",
63 });
64
65 expect(combineTerminalSessionState(summary, attached)).toMatchObject({
66 status: "error",
67 error: "Terminal disconnected.",
68 version: 1,
69 });
70 });
71
72 it("uses metadata status before an attach stream has emitted", () => {
73 const summary = applyTerminalMetadataStreamEvent([], {
74 type: "snapshot",
75 terminals: [
76 {
77 threadId: BASE_SNAPSHOT.threadId,
78 terminalId: BASE_SNAPSHOT.terminalId,
79 cwd: BASE_SNAPSHOT.cwd,
80 worktreePath: BASE_SNAPSHOT.worktreePath,
81 status: "running",
82 pid: BASE_SNAPSHOT.pid,
83 exitCode: BASE_SNAPSHOT.exitCode,
84 exitSignal: BASE_SNAPSHOT.exitSignal,
85 updatedAt: BASE_SNAPSHOT.updatedAt,
86 hasRunningSubprocess: false,
87 label: BASE_SNAPSHOT.label,
88 },
89 ],
90 })[0]!;
91
92 expect(combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE).status).toBe(
93 "running",
94 );
95 });
96
97 it("does not treat an idle running shell as a running subprocess", () => {
98 const idleSession = {
99 target: TARGET,
100 state: {
101 ...combineTerminalSessionState(null, EMPTY_TERMINAL_BUFFER_STATE),
102 status: "running" as const,
103 hasRunningSubprocess: false,
104 },
105 };
106 const activeSession = {
107 target: { ...TARGET, terminalId: "term-2" },
108 state: {
109 ...idleSession.state,
110 hasRunningSubprocess: true,
111 },
112 };
113
114 expect(selectRunningSubprocessTerminalIds([idleSession, activeSession])).toEqual(["term-2"]);
115 });
116
117 it("reduces attach snapshots and output without an imperative session manager", () => {
118 const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
119 type: "snapshot",
120 snapshot: BASE_SNAPSHOT,
121 });
122 const output = applyTerminalAttachStreamEvent(
123 snapshot,
124 {
125 type: "output",
126 threadId: TARGET.threadId,
127 terminalId: TARGET.terminalId,
128 data: " world",
129 },
130 8,
131 );
132
133 expect(output).toMatchObject({
134 status: "running",
135 error: null,
136 version: 2,
137 });
138 expect(terminalOutputText(output.output)).toBe("lo world");
139 });
140
141 it("does not advance the lifecycle for the initial attach snapshot", () => {
142 const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
143 type: "snapshot",
144 snapshot: BASE_SNAPSHOT,
145 });
146
147 expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 });
148 });
149
150 it("advances the lifecycle for a live started snapshot", () => {
151 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
152 type: "snapshot",
153 snapshot: BASE_SNAPSHOT,
154 });
155 const started = applyTerminalAttachStreamEvent(initial, {
156 type: "snapshot",
157 snapshot: { ...BASE_SNAPSHOT, pid: 456 },
158 });
159
160 expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 });
161 });
162
163 it("advances the lifecycle when a running terminal restarts in place", () => {
164 const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
165 type: "snapshot",
166 snapshot: BASE_SNAPSHOT,
167 });
168 const restarted = applyTerminalAttachStreamEvent(snapshot, {
169 type: "restarted",
170 threadId: TARGET.threadId,
171 terminalId: TARGET.terminalId,
172 snapshot: { ...BASE_SNAPSHOT, pid: 456 },
173 });
174
175 expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 });
176 expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 });
177 });
178
179 it("reduces terminal metadata snapshots, upserts, and removals", () => {
180 const initial = applyTerminalMetadataStreamEvent([], {
181 type: "snapshot",
182 terminals: [
183 {
184 threadId: BASE_SNAPSHOT.threadId,
185 terminalId: BASE_SNAPSHOT.terminalId,
186 cwd: BASE_SNAPSHOT.cwd,
187 worktreePath: BASE_SNAPSHOT.worktreePath,
188 status: BASE_SNAPSHOT.status,
189 pid: BASE_SNAPSHOT.pid,
190 exitCode: BASE_SNAPSHOT.exitCode,
191 exitSignal: BASE_SNAPSHOT.exitSignal,
192 updatedAt: BASE_SNAPSHOT.updatedAt,
193 hasRunningSubprocess: false,
194 label: BASE_SNAPSHOT.label,
195 },
196 ],
197 });
198 const updated = applyTerminalMetadataStreamEvent(initial, {
199 type: "upsert",
200 terminal: {
201 ...initial[0]!,
202 hasRunningSubprocess: true,
203 },
204 });
205 const removed = applyTerminalMetadataStreamEvent(updated, {
206 type: "remove",
207 threadId: TARGET.threadId,
208 terminalId: TARGET.terminalId,
209 });
210
211 expect(updated).toHaveLength(1);
212 expect(updated[0]?.hasRunningSubprocess).toBe(true);
213 expect(removed).toEqual([]);
214 });
215
216 it("caps retained output by UTF-8 byte length", () => {
217 const state = applyTerminalAttachStreamEvent(
218 EMPTY_TERMINAL_BUFFER_STATE,
219 {
220 type: "output",
221 threadId: TARGET.threadId,
222 terminalId: TARGET.terminalId,
223 data: "πŸ™‚πŸ™‚",
224 },
225 4,
226 );
227
228 expect(terminalOutputText(state.output)).toBe("πŸ™‚");
229 });
230
231 it("preserves a BOM code point at a new output chunk boundary", () => {
232 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
233 type: "snapshot",
234 snapshot: { ...BASE_SNAPSHOT, history: "" },
235 });
236 const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
237 const data = `${"x".repeat(16_384)}\uFEFF${"y".repeat(20)}`;
238 const state = applyTerminalAttachStreamEvent(initial, {
239 type: "output",
240 threadId: TARGET.threadId,
241 terminalId: TARGET.terminalId,
242 data,
243 });
244
245 expect(terminalOutputText(state.output)).toBe(data);
246 expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data });
247 expect(state.output.retainedBytes).toBe(new TextEncoder().encode(data).byteLength);
248 });
249
250 it("preserves a leading BOM in the retained snapshot tail", () => {
251 const state = applyTerminalAttachStreamEvent(
252 EMPTY_TERMINAL_BUFFER_STATE,
253 { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "discard\uFEFFtail" } },
254 7,
255 );
256
257 expect(terminalOutputText(state.output)).toBe("\uFEFFtail");
258 expect(state.output.retainedBytes).toBe(7);
259 });
260
261 it("trims whole Unicode code points from a partially retained chunk", () => {
262 let state = applyTerminalAttachStreamEvent(
263 EMPTY_TERMINAL_BUFFER_STATE,
264 { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "éη•ŒπŸ™‚end" } },
265 12,
266 );
267 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
268 for (const [data, expected, retainedBytes] of [
269 ["x", "η•ŒπŸ™‚endx", 11],
270 ["yz", "πŸ™‚endxyz", 10],
271 ["abc", "endxyzabc", 9],
272 ] as const) {
273 state = applyTerminalAttachStreamEvent(
274 state,
275 { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
276 12,
277 );
278 const update = readTerminalOutputUpdate(state.output, cursor);
279 expect(update).toMatchObject({ type: "append", data });
280 expect(terminalOutputText(state.output)).toBe(expected);
281 expect(state.output.retainedBytes).toBe(retainedBytes);
282 cursor = update.cursor;
283 }
284 });
285
286 it("delivers all output when several events arrive before a renderer reads", () => {
287 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
288 type: "snapshot",
289 snapshot: BASE_SNAPSHOT,
290 });
291 const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
292 let state = initial;
293 for (const data of [" one", " two", " three"]) {
294 state = applyTerminalAttachStreamEvent(state, {
295 type: "output",
296 threadId: TARGET.threadId,
297 terminalId: TARGET.terminalId,
298 data,
299 });
300 }
301 const update = readTerminalOutputUpdate(state.output, cursor);
302 expect(update).toMatchObject({ type: "append", data: " one two three" });
303 expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none");
304 });
305
306 it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => {
307 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
308 type: "snapshot",
309 snapshot: { ...BASE_SNAPSHOT, history: "" },
310 });
311 const staleCursor = readTerminalOutputUpdate(
312 initial.output,
313 INITIAL_TERMINAL_OUTPUT_CURSOR,
314 ).cursor;
315 const first = applyTerminalAttachStreamEvent(initial, {
316 type: "output",
317 threadId: TARGET.threadId,
318 terminalId: TARGET.terminalId,
319 data: "hello",
320 });
321 const caughtUpCursor = readTerminalOutputUpdate(first.output, staleCursor).cursor;
322 const state = applyTerminalAttachStreamEvent(
323 first,
324 { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world" },
325 8,
326 );
327
328 expect(readTerminalOutputUpdate(state.output, caughtUpCursor)).toMatchObject({
329 type: "append",
330 data: " world",
331 });
332 expect(readTerminalOutputUpdate(state.output, staleCursor)).toMatchObject({
333 type: "reset",
334 data: "lo world",
335 });
336 expect(state.output.retainedBytes).toBe(8);
337 });
338
339 it("does not encode retained history again when appending at the byte limit", () => {
340 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
341 type: "snapshot",
342 snapshot: { ...BASE_SNAPSHOT, history: "x".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES) },
343 });
344 const data = "y".repeat(8192);
345 const encode = vi.spyOn(TextEncoder.prototype, "encode");
346 try {
347 for (let index = 0; index < 100; index += 1) {
348 state = applyTerminalAttachStreamEvent(state, {
349 type: "output",
350 threadId: TARGET.threadId,
351 terminalId: TARGET.terminalId,
352 data,
353 });
354 }
355 expect(encode.mock.calls.reduce((total, [text]) => total + (text?.length ?? 0), 0)).toBe(
356 data.length * 100,
357 );
358 expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES);
359 } finally {
360 encode.mockRestore();
361 }
362 });
363
364 it.each([0, -1])("discards output without encoding when the byte budget is %s", (maxBytes) => {
365 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
366 type: "snapshot",
367 snapshot: BASE_SNAPSHOT,
368 });
369 const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
370 const data = "x".repeat(65_536);
371 const encode = vi.spyOn(TextEncoder.prototype, "encode");
372 try {
373 const discarded = applyTerminalAttachStreamEvent(
374 initial,
375 { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
376 maxBytes,
377 );
378 expect(encode).not.toHaveBeenCalled();
379 expect(discarded.output.nextOffset).toBe(initial.output.nextOffset + data.length);
380 expect(discarded.output.retainedBytes).toBe(0);
381 expect(readTerminalOutputUpdate(discarded.output, cursor)).toMatchObject({
382 type: "reset",
383 data: "",
384 });
385
386 const empty = applyTerminalAttachStreamEvent(
387 initial,
388 { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "" },
389 maxBytes,
390 );
391 expect(empty.output).toBe(initial.output);
392 expect(encode).not.toHaveBeenCalled();
393 } finally {
394 encode.mockRestore();
395 }
396 });
397
398 it("resets for repeated snapshots, clear, and restart even when output text repeats", () => {
399 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
400 type: "snapshot",
401 snapshot: BASE_SNAPSHOT,
402 });
403 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
404 state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT });
405 const repeated = readTerminalOutputUpdate(state.output, cursor);
406 expect(repeated).toMatchObject({ type: "reset", data: "hello" });
407 expect(state.version).toBe(2);
408 cursor = repeated.cursor;
409
410 state = applyTerminalAttachStreamEvent(state, {
411 type: "cleared",
412 threadId: TARGET.threadId,
413 terminalId: TARGET.terminalId,
414 });
415 const cleared = readTerminalOutputUpdate(state.output, cursor);
416 expect(cleared).toMatchObject({ type: "reset", data: "" });
417 state = applyTerminalAttachStreamEvent(state, {
418 type: "restarted",
419 threadId: TARGET.threadId,
420 terminalId: TARGET.terminalId,
421 snapshot: BASE_SNAPSHOT,
422 });
423 expect(readTerminalOutputUpdate(state.output, cleared.cursor)).toMatchObject({
424 type: "reset",
425 data: "hello",
426 });
427 expect(state.lifecycleVersion).toBe(2);
428 });
429
430 it("does not reuse a renderer cursor when a fresh attach has matching counters", () => {
431 const first = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
432 type: "snapshot",
433 snapshot: BASE_SNAPSHOT,
434 });
435 const cursor = readTerminalOutputUpdate(first.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
436 const next = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
437 type: "snapshot",
438 snapshot: { ...BASE_SNAPSHOT, history: "other" },
439 });
440 expect(next.output.resetVersion).toBe(first.output.resetVersion);
441 expect(next.output.nextOffset).toBe(first.output.nextOffset);
442 expect(readTerminalOutputUpdate(next.output, cursor)).toMatchObject({
443 type: "reset",
444 data: "other",
445 });
446 expect(next.lifecycleVersion).toBe(0);
447 });
448
449 it("keeps appending while compacting metadata for many small writes", () => {
450 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
451 type: "snapshot",
452 snapshot: { ...BASE_SNAPSHOT, history: "" },
453 });
454 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
455 let received = "";
456 for (let index = 0; index < 2500; index += 1) {
457 state = applyTerminalAttachStreamEvent(state, {
458 type: "output",
459 threadId: TARGET.threadId,
460 terminalId: TARGET.terminalId,
461 data: "x",
462 });
463 const update = readTerminalOutputUpdate(state.output, cursor);
464 if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`);
465 received += update.data;
466 cursor = update.cursor;
467 }
468 expect(received).toBe("x".repeat(2500));
469 expect(state.output.chunks.length).toBeLessThan(1024);
470 expect(terminalOutputText(state.output)).toBe(received);
471 });
472
473 it("appends every unread character across a compaction boundary", () => {
474 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
475 type: "snapshot",
476 snapshot: { ...BASE_SNAPSHOT, history: "" },
477 });
478 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
479 const writes = Array.from({ length: 1200 }, (_, index) => ["x", "é", "η•Œ", "πŸ™‚"][index % 4]!);
480 for (const [index, data] of writes.entries()) {
481 state = applyTerminalAttachStreamEvent(state, {
482 type: "output",
483 threadId: TARGET.threadId,
484 terminalId: TARGET.terminalId,
485 data,
486 });
487 if (index === 99) cursor = readTerminalOutputUpdate(state.output, cursor).cursor;
488 }
489 expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({
490 type: "append",
491 data: writes.slice(100).join(""),
492 });
493 });
494});