import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices";import { assert, it } from "@effect/vitest";import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput, type TerminalRestartInput,} from "@t3tools/contracts";import { Duration, Effect, Encoding, Exit, Fiber, FileSystem, Option, PlatformError, Ref, Schedule, Scope,} from "effect";import { TestClock } from "effect/testing";import { expect } from "vitest"; import type { TerminalManagerShape } from "../Services/Manager";import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError,} from "../Services/PTY";import { makeTerminalManagerWithOptions } from "./Manager"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array<string | undefined> = []; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; constructor(readonly pid: number) {} write(data: string): void { this.writes.push(data); } resize(cols: number, rows: number): void { this.resizeCalls.push({ cols, rows }); } kill(signal?: string): void { this.killed = true; this.killSignals.push(signal); } onData(callback: (data: string) => void): () => void { this.dataListeners.add(callback); return () => { this.dataListeners.delete(callback); }; } onExit(callback: (event: PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); }; } emitData(data: string): void { for (const listener of this.dataListeners) { listener(data); } } emitExit(event: PtyExitEvent): void { for (const listener of this.exitListeners) { listener(event); } }} class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; private nextPid = 9000; constructor(private readonly mode: "sync" | "async" = "sync") {} spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError> { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { return Effect.fail( new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process", cause: failure, }), ); } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ try: async () => process, catch: (cause) => new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process", cause, }), }); } return Effect.succeed(process); }} const waitFor = <E, R>( predicate: Effect.Effect<boolean, E, R>, timeout: Duration.Input = 800,): Effect.Effect<void, Error | E, R> => predicate.pipe( Effect.filterOrFail( (done) => done, () => new Error("Condition not met"), ), Effect.retry(Schedule.spaced("15 millis")), Effect.timeoutOption(timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(new Error("Timed out waiting for condition")), onSome: () => Effect.void, }), ), ); function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, };} function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, };} function historyLogName(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;} function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; if (terminalId === DEFAULT_TERMINAL_ID) { return `${threadPart}.log`; } return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`;} function historyLogPath(logsDir: string, threadId = "thread-1"): string { return path.join(logsDir, historyLogName(threadId));} function multiTerminalHistoryLogPath( logsDir: string, threadId = "thread-1", terminalId = "default",): string { return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));} interface CreateManagerOptions { shellResolver?: () => string; subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; ptyAdapter?: FakePtyAdapter;} interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; readonly manager: TerminalManagerShape; readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;} const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {},): Effect.Effect< ManagerFixture, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); const logsDir = path.join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); const manager = yield* makeTerminalManagerWithOptions({ logsDir, historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), ...(options.processKillGraceMs !== undefined ? { processKillGraceMs: options.processKillGraceMs } : {}), ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); const scope = yield* Effect.scope; const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), ); yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); return { baseDir, logsDir, ptyAdapter, manager, getEvents: Ref.get(eventsRef), }; }), ); it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const [first, second] = yield* Effect.all( [manager.open(openInput()), manager.open(openInput())], { concurrency: "unbounded" }, ); const third = yield* manager.open(openInput()); assert.equal(first.threadId, "thread-1"); assert.equal(first.terminalId, "default"); assert.equal(second.threadId, "thread-1"); assert.equal(third.threadId, "thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); const makeDirectory = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.makeDirectory(filePath, { recursive: true }), ); const chmod = (filePath: string, mode: number) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); const readFileString = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); const writeFileString = (filePath: string, contents: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.writeFileString(filePath, contents), ); it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); yield* makeDirectory(blockedCwd); yield* chmod(blockedRoot, 0o000); const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), ); expect(error).toMatchObject({ _tag: "TerminalCwdError", cwd: blockedCwd, reason: "statFailed", }); }), ); it.effect("supports asynchronous PTY spawn effects", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { ptyAdapter: new FakePtyAdapter("async"), }); const snapshot = yield* manager.open(openInput()); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(1); expect(ptyAdapter.processes).toHaveLength(1); }), ); it.effect("forwards write and resize to active pty process", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; yield* manager.write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "ls\n", }); yield* manager.resize({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 120, rows: 30, }); expect(process.writes).toEqual(["ls\n"]); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); }), ); it.effect("resizes running terminal on open when a different size is requested", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput({ cols: 100, rows: 24 })); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); assert.equal(reopened.status, "running"); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); }), ); it.effect("supports multiple terminals per thread independently", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput({ terminalId: "default" })); yield* manager.open(openInput({ terminalId: "term-2" })); const first = ptyAdapter.processes[0]; const second = ptyAdapter.processes[1]; expect(first).toBeDefined(); expect(second).toBeDefined(); if (!first || !second) return; yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); expect(first.writes).toEqual(["pwd\n"]); expect(second.writes).toEqual(["ls\n"]); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); it.effect("clears transcript and emits cleared event", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); const events = yield* getEvents; expect(events.some((event) => event.type === "cleared")).toBe(true); expect( events.some( (event) => event.type === "cleared" && event.threadId === "thread-1" && event.terminalId === "default", ), ).toBe(true); }), ); it.effect("restarts terminal with empty transcript and respawns pty", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir } = yield* createManager(); yield* manager.open(openInput()); const firstProcess = ptyAdapter.processes[0]; expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); const snapshot = yield* manager.restart(restartInput()); assert.equal(snapshot.history, ""); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); }), ); it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => Effect.gen(function* () { const { manager, getEvents, baseDir } = yield* createManager(); const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); yield* makeDirectory(firstWorktreePath); yield* makeDirectory(secondWorktreePath); const startedSnapshot = yield* manager.open( openInput({ cwd: firstWorktreePath, worktreePath: firstWorktreePath, }), ); const restartedSnapshot = yield* manager.restart( restartInput({ cwd: secondWorktreePath, worktreePath: secondWorktreePath, }), ); assert.equal(startedSnapshot.worktreePath, firstWorktreePath); assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); const events = yield* getEvents; const startedEvent = events.find( (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started", ); const restartedEvent = events.find( (event): event is Extract<TerminalEvent, { type: "restarted" }> => event.type === "restarted", ); assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); }), ); it.effect("preserves worktree metadata when reopening an exited session", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); const worktreePath = path.join(baseDir, "worktrees", "feature-a"); yield* makeDirectory(worktreePath); yield* manager.open( openInput({ cwd: worktreePath, worktreePath, }), ); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), ); const reopenedSnapshot = yield* manager.open( openInput({ cwd: worktreePath, worktreePath, }), ); assert.equal(reopenedSnapshot.worktreePath, worktreePath); const events = yield* getEvents; const reopenedEvent = eventsLines 1–500 of 1041. Load more
import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; import { Duration, Effect, Encoding, Exit, Fiber, FileSystem, Option, PlatformError, Ref, Schedule, Scope, } from "effect"; import { TestClock } from "effect/testing"; import { expect } from "vitest"; import type { TerminalManagerShape } from "../Services/Manager"; import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError, } from "../Services/PTY"; import { makeTerminalManagerWithOptions } from "./Manager"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array<string | undefined> = []; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; constructor(readonly pid: number) {} write(data: string): void { this.writes.push(data); } resize(cols: number, rows: number): void { this.resizeCalls.push({ cols, rows }); } kill(signal?: string): void { this.killed = true; this.killSignals.push(signal); } onData(callback: (data: string) => void): () => void { this.dataListeners.add(callback); return () => { this.dataListeners.delete(callback); }; } onExit(callback: (event: PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); }; } emitData(data: string): void { for (const listener of this.dataListeners) { listener(data); } } emitExit(event: PtyExitEvent): void { for (const listener of this.exitListeners) { listener(event); } } } class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; private nextPid = 9000; constructor(private readonly mode: "sync" | "async" = "sync") {} spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError> { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { return Effect.fail( new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process", cause: failure, }), ); } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ try: async () => process, catch: (cause) => new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process", cause, }), }); } return Effect.succeed(process); } } const waitFor = <E, R>( predicate: Effect.Effect<boolean, E, R>, timeout: Duration.Input = 800, ): Effect.Effect<void, Error | E, R> => predicate.pipe( Effect.filterOrFail( (done) => done, () => new Error("Condition not met"), ), Effect.retry(Schedule.spaced("15 millis")), Effect.timeoutOption(timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(new Error("Timed out waiting for condition")), onSome: () => Effect.void, }), ), ); function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } function historyLogName(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; } function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; if (terminalId === DEFAULT_TERMINAL_ID) { return `${threadPart}.log`; } return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; } function historyLogPath(logsDir: string, threadId = "thread-1"): string { return path.join(logsDir, historyLogName(threadId)); } function multiTerminalHistoryLogPath( logsDir: string, threadId = "thread-1", terminalId = "default", ): string { return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); } interface CreateManagerOptions { shellResolver?: () => string; subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; ptyAdapter?: FakePtyAdapter; } interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; readonly manager: TerminalManagerShape; readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>; } const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {}, ): Effect.Effect< ManagerFixture, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope > => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); const logsDir = path.join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); const manager = yield* makeTerminalManagerWithOptions({ logsDir, historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), ...(options.processKillGraceMs !== undefined ? { processKillGraceMs: options.processKillGraceMs } : {}), ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); const scope = yield* Effect.scope; const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), ); yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); return { baseDir, logsDir, ptyAdapter, manager, getEvents: Ref.get(eventsRef), }; }), ); it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const [first, second] = yield* Effect.all( [manager.open(openInput()), manager.open(openInput())], { concurrency: "unbounded" }, ); const third = yield* manager.open(openInput()); assert.equal(first.threadId, "thread-1"); assert.equal(first.terminalId, "default"); assert.equal(second.threadId, "thread-1"); assert.equal(third.threadId, "thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); const makeDirectory = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.makeDirectory(filePath, { recursive: true }), ); const chmod = (filePath: string, mode: number) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); const readFileString = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); const writeFileString = (filePath: string, contents: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.writeFileString(filePath, contents), ); it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); yield* makeDirectory(blockedCwd); yield* chmod(blockedRoot, 0o000); const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), ); expect(error).toMatchObject({ _tag: "TerminalCwdError", cwd: blockedCwd, reason: "statFailed", }); }), ); it.effect("supports asynchronous PTY spawn effects", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { ptyAdapter: new FakePtyAdapter("async"), }); const snapshot = yield* manager.open(openInput()); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(1); expect(ptyAdapter.processes).toHaveLength(1); }), ); it.effect("forwards write and resize to active pty process", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; yield* manager.write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "ls\n", }); yield* manager.resize({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 120, rows: 30, }); expect(process.writes).toEqual(["ls\n"]); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); }), ); it.effect("resizes running terminal on open when a different size is requested", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput({ cols: 100, rows: 24 })); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); assert.equal(reopened.status, "running"); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); }), ); it.effect("supports multiple terminals per thread independently", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput({ terminalId: "default" })); yield* manager.open(openInput({ terminalId: "term-2" })); const first = ptyAdapter.processes[0]; const second = ptyAdapter.processes[1]; expect(first).toBeDefined(); expect(second).toBeDefined(); if (!first || !second) return; yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); expect(first.writes).toEqual(["pwd\n"]); expect(second.writes).toEqual(["ls\n"]); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); it.effect("clears transcript and emits cleared event", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); const events = yield* getEvents; expect(events.some((event) => event.type === "cleared")).toBe(true); expect( events.some( (event) => event.type === "cleared" && event.threadId === "thread-1" && event.terminalId === "default", ), ).toBe(true); }), ); it.effect("restarts terminal with empty transcript and respawns pty", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir } = yield* createManager(); yield* manager.open(openInput()); const firstProcess = ptyAdapter.processes[0]; expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); const snapshot = yield* manager.restart(restartInput()); assert.equal(snapshot.history, ""); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); }), ); it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => Effect.gen(function* () { const { manager, getEvents, baseDir } = yield* createManager(); const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); yield* makeDirectory(firstWorktreePath); yield* makeDirectory(secondWorktreePath); const startedSnapshot = yield* manager.open( openInput({ cwd: firstWorktreePath, worktreePath: firstWorktreePath, }), ); const restartedSnapshot = yield* manager.restart( restartInput({ cwd: secondWorktreePath, worktreePath: secondWorktreePath, }), ); assert.equal(startedSnapshot.worktreePath, firstWorktreePath); assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); const events = yield* getEvents; const startedEvent = events.find( (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started", ); const restartedEvent = events.find( (event): event is Extract<TerminalEvent, { type: "restarted" }> => event.type === "restarted", ); assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); }), ); it.effect("preserves worktree metadata when reopening an exited session", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); const worktreePath = path.join(baseDir, "worktrees", "feature-a"); yield* makeDirectory(worktreePath); yield* manager.open( openInput({ cwd: worktreePath, worktreePath, }), ); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), ); const reopenedSnapshot = yield* manager.open( openInput({ cwd: worktreePath, worktreePath, }), ); assert.equal(reopenedSnapshot.worktreePath, worktreePath); const events = yield* getEvents; const reopenedEvent = events