import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { applyTerminalAttachStreamEvent, applyTerminalMetadataStreamEvent, combineTerminalSessionState, DEFAULT_MAX_TERMINAL_BUFFER_BYTES, EMPTY_TERMINAL_BUFFER_STATE, INITIAL_TERMINAL_OUTPUT_CURSOR, nextTerminalAttachSeedState, readTerminalOutputUpdate, selectRunningSubprocessTerminalIds, terminalOutputText, } from "./terminalSession.ts"; const TARGET = { environmentId: EnvironmentId.make("env-local"), threadId: ThreadId.make("thread-1"), terminalId: "term-1", } as const; const BASE_SNAPSHOT: TerminalSessionSnapshot = { threadId: TARGET.threadId, terminalId: TARGET.terminalId, cwd: "/repo", worktreePath: null, status: "running", pid: 123, history: "hello", exitCode: null, exitSignal: null, label: "Terminal 1", updatedAt: "2026-04-01T00:00:00.000Z", }; describe("terminal session reducers", () => { it("prefers live attach status over stale metadata after the attach stream starts", () => { const summary = applyTerminalMetadataStreamEvent([], { type: "snapshot", terminals: [ { threadId: BASE_SNAPSHOT.threadId, terminalId: BASE_SNAPSHOT.terminalId, cwd: BASE_SNAPSHOT.cwd, worktreePath: BASE_SNAPSHOT.worktreePath, status: "running", pid: BASE_SNAPSHOT.pid, exitCode: BASE_SNAPSHOT.exitCode, exitSignal: BASE_SNAPSHOT.exitSignal, updatedAt: BASE_SNAPSHOT.updatedAt, hasRunningSubprocess: false, label: BASE_SNAPSHOT.label, }, ], })[0]!; const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "error", threadId: TARGET.threadId, terminalId: TARGET.terminalId, message: "Terminal disconnected.", }); expect(combineTerminalSessionState(summary, attached)).toMatchObject({ status: "error", error: "Terminal disconnected.", version: 1, }); }); it("uses metadata status before an attach stream has emitted", () => { const summary = applyTerminalMetadataStreamEvent([], { type: "snapshot", terminals: [ { threadId: BASE_SNAPSHOT.threadId, terminalId: BASE_SNAPSHOT.terminalId, cwd: BASE_SNAPSHOT.cwd, worktreePath: BASE_SNAPSHOT.worktreePath, status: "running", pid: BASE_SNAPSHOT.pid, exitCode: BASE_SNAPSHOT.exitCode, exitSignal: BASE_SNAPSHOT.exitSignal, updatedAt: BASE_SNAPSHOT.updatedAt, hasRunningSubprocess: false, label: BASE_SNAPSHOT.label, }, ], })[0]!; expect(combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE).status).toBe( "running", ); }); it("does not treat an idle running shell as a running subprocess", () => { const idleSession = { target: TARGET, state: { ...combineTerminalSessionState(null, EMPTY_TERMINAL_BUFFER_STATE), status: "running" as const, hasRunningSubprocess: false, }, }; const activeSession = { target: { ...TARGET, terminalId: "term-2" }, state: { ...idleSession.state, hasRunningSubprocess: true, }, }; expect(selectRunningSubprocessTerminalIds([idleSession, activeSession])).toEqual(["term-2"]); }); it("reduces attach snapshots and output without an imperative session manager", () => { const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); const output = applyTerminalAttachStreamEvent( snapshot, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world", }, 8, ); expect(output).toMatchObject({ status: "running", error: null, version: 2, }); expect(terminalOutputText(output.output)).toBe("lo world"); }); it("does not advance the lifecycle for the initial attach snapshot", () => { const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); }); it("advances the lifecycle for a live started snapshot", () => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); const started = applyTerminalAttachStreamEvent(initial, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, pid: 456 }, }); expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 }); }); it("advances the lifecycle when a running terminal restarts in place", () => { const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); const restarted = applyTerminalAttachStreamEvent(snapshot, { type: "restarted", threadId: TARGET.threadId, terminalId: TARGET.terminalId, snapshot: { ...BASE_SNAPSHOT, pid: 456 }, }); expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 }); }); it("reduces terminal metadata snapshots, upserts, and removals", () => { const initial = applyTerminalMetadataStreamEvent([], { type: "snapshot", terminals: [ { threadId: BASE_SNAPSHOT.threadId, terminalId: BASE_SNAPSHOT.terminalId, cwd: BASE_SNAPSHOT.cwd, worktreePath: BASE_SNAPSHOT.worktreePath, status: BASE_SNAPSHOT.status, pid: BASE_SNAPSHOT.pid, exitCode: BASE_SNAPSHOT.exitCode, exitSignal: BASE_SNAPSHOT.exitSignal, updatedAt: BASE_SNAPSHOT.updatedAt, hasRunningSubprocess: false, label: BASE_SNAPSHOT.label, }, ], }); const updated = applyTerminalMetadataStreamEvent(initial, { type: "upsert", terminal: { ...initial[0]!, hasRunningSubprocess: true, }, }); const removed = applyTerminalMetadataStreamEvent(updated, { type: "remove", threadId: TARGET.threadId, terminalId: TARGET.terminalId, }); expect(updated).toHaveLength(1); expect(updated[0]?.hasRunningSubprocess).toBe(true); expect(removed).toEqual([]); }); it("caps retained output by UTF-8 byte length", () => { const state = applyTerminalAttachStreamEvent( EMPTY_TERMINAL_BUFFER_STATE, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "πŸ™‚πŸ™‚", }, 4, ); expect(terminalOutputText(state.output)).toBe("πŸ™‚"); }); it("preserves a BOM code point at a new output chunk boundary", () => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" }, }); const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; const data = `${"x".repeat(16_384)}\uFEFF${"y".repeat(20)}`; const state = applyTerminalAttachStreamEvent(initial, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data, }); expect(terminalOutputText(state.output)).toBe(data); expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data }); expect(state.output.retainedBytes).toBe(new TextEncoder().encode(data).byteLength); }); it("preserves a leading BOM in the retained snapshot tail", () => { const state = applyTerminalAttachStreamEvent( EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "discard\uFEFFtail" } }, 7, ); expect(terminalOutputText(state.output)).toBe("\uFEFFtail"); expect(state.output.retainedBytes).toBe(7); }); it("trims whole Unicode code points from a partially retained chunk", () => { let state = applyTerminalAttachStreamEvent( EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "Γ©η•ŒπŸ™‚end" } }, 12, ); let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; for (const [data, expected, retainedBytes] of [ ["x", "η•ŒπŸ™‚endx", 11], ["yz", "πŸ™‚endxyz", 10], ["abc", "endxyzabc", 9], ] as const) { state = applyTerminalAttachStreamEvent( state, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data }, 12, ); const update = readTerminalOutputUpdate(state.output, cursor); expect(update).toMatchObject({ type: "append", data }); expect(terminalOutputText(state.output)).toBe(expected); expect(state.output.retainedBytes).toBe(retainedBytes); cursor = update.cursor; } }); it("delivers all output when several events arrive before a renderer reads", () => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; let state = initial; for (const data of [" one", " two", " three"]) { state = applyTerminalAttachStreamEvent(state, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data, }); } const update = readTerminalOutputUpdate(state.output, cursor); expect(update).toMatchObject({ type: "append", data: " one two three" }); expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none"); }); it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" }, }); const staleCursor = readTerminalOutputUpdate( initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR, ).cursor; const first = applyTerminalAttachStreamEvent(initial, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "hello", }); const caughtUpCursor = readTerminalOutputUpdate(first.output, staleCursor).cursor; const state = applyTerminalAttachStreamEvent( first, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world" }, 8, ); expect(readTerminalOutputUpdate(state.output, caughtUpCursor)).toMatchObject({ type: "append", data: " world", }); expect(readTerminalOutputUpdate(state.output, staleCursor)).toMatchObject({ type: "reset", data: "lo world", }); expect(state.output.retainedBytes).toBe(8); }); it("does not encode retained history again when appending at the byte limit", () => { let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "x".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES) }, }); const data = "y".repeat(8192); const encode = vi.spyOn(TextEncoder.prototype, "encode"); try { for (let index = 0; index < 100; index += 1) { state = applyTerminalAttachStreamEvent(state, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data, }); } expect(encode.mock.calls.reduce((total, [text]) => total + (text?.length ?? 0), 0)).toBe( data.length * 100, ); expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); } finally { encode.mockRestore(); } }); it.each([0, -1])("discards output without encoding when the byte budget is %s", (maxBytes) => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; const data = "x".repeat(65_536); const encode = vi.spyOn(TextEncoder.prototype, "encode"); try { const discarded = applyTerminalAttachStreamEvent( initial, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data }, maxBytes, ); expect(encode).not.toHaveBeenCalled(); expect(discarded.output.nextOffset).toBe(initial.output.nextOffset + data.length); expect(discarded.output.retainedBytes).toBe(0); expect(readTerminalOutputUpdate(discarded.output, cursor)).toMatchObject({ type: "reset", data: "", }); const empty = applyTerminalAttachStreamEvent( initial, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "" }, maxBytes, ); expect(empty.output).toBe(initial.output); expect(encode).not.toHaveBeenCalled(); } finally { encode.mockRestore(); } }); it("resets for repeated snapshots, clear, and restart even when output text repeats", () => { let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: BASE_SNAPSHOT, }); let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT }); const repeated = readTerminalOutputUpdate(state.output, cursor); expect(repeated).toMatchObject({ type: "reset", data: "hello" }); expect(state.version).toBe(2); cursor = repeated.cursor; state = applyTerminalAttachStreamEvent(state, { type: "cleared", threadId: TARGET.threadId, terminalId: TARGET.terminalId, }); const cleared = readTerminalOutputUpdate(state.output, cursor); expect(cleared).toMatchObject({ type: "reset", data: "" }); state = applyTerminalAttachStreamEvent(state, { type: "restarted", threadId: TARGET.threadId, terminalId: TARGET.terminalId, snapshot: BASE_SNAPSHOT, }); expect(readTerminalOutputUpdate(state.output, cleared.cursor)).toMatchObject({ type: "reset", data: "hello", }); expect(state.lifecycleVersion).toBe(2); }); it("does not reuse a renderer cursor when a fresh attach has matching counters", () => { const first = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { type: "snapshot", snapshot: BASE_SNAPSHOT, }); const cursor = readTerminalOutputUpdate(first.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; const next = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "other" }, }); expect(next.output.resetVersion).toBe(first.output.resetVersion); expect(next.output.nextOffset).toBe(first.output.nextOffset); expect(readTerminalOutputUpdate(next.output, cursor)).toMatchObject({ type: "reset", data: "other", }); expect(next.lifecycleVersion).toBe(0); }); it("keeps appending while compacting metadata for many small writes", () => { let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" }, }); let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; let received = ""; for (let index = 0; index < 2500; index += 1) { state = applyTerminalAttachStreamEvent(state, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: "x", }); const update = readTerminalOutputUpdate(state.output, cursor); if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); received += update.data; cursor = update.cursor; } expect(received).toBe("x".repeat(2500)); expect(state.output.chunks.length).toBeLessThan(1024); expect(terminalOutputText(state.output)).toBe(received); }); it("appends every unread character across a compaction boundary", () => { let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" }, }); let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; const writes = Array.from({ length: 1200 }, (_, index) => ["x", "Γ©", "η•Œ", "πŸ™‚"][index % 4]!); for (const [index, data] of writes.entries()) { state = applyTerminalAttachStreamEvent(state, { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data, }); if (index === 99) cursor = readTerminalOutputUpdate(state.output, cursor).cursor; } expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data: writes.slice(100).join(""), }); }); });