fix(FN-771): reset disclosure state on task creation and preserve terminal sessions across reconnects

- Reset isDisclosureExpanded in QuickEntryBox resetForm so disclosure collapses after task creation
- Remove sticky disclosure persistence from QuickEntryBox component
- Preserve terminal sessions across transient WebSocket disconnects with buffer/replay
- Buffer and replay initial terminal state for prompt visibility on reconnect
- Update tests for non-persistent disclosure, terminal reconnect, and server routes
- Remove unused UsageIndicator tests and TaskDetailModal test cleanup
This commit is contained in:
gsxdsm
2026-04-03 06:47:40 -07:00
parent 2f68eba278
commit df73a82917
8 changed files with 401 additions and 67 deletions

View File

@@ -4922,10 +4922,9 @@ describe("Terminal session routes", () => {
});
describe("Terminal WebSocket close handler", () => {
it("kills PTY session when WebSocket closes", async () => {
// This tests the server.ts close handler logic by verifying that
// setupTerminalWebSocket's close handler calls killSession.
// We import server.ts and mock the terminal service.
it("does NOT kill PTY session when WebSocket closes (session persists for reconnect)", async () => {
// After FN-762, closing a WebSocket must not destroy the PTY session.
// The session survives transient disconnects and modal close/reopen cycles.
const killSessionMock = vi.fn().mockReturnValue(true);
const getSessionMock = vi.fn().mockReturnValue({
id: "term-ws-test",
@@ -4949,7 +4948,6 @@ describe("Terminal WebSocket close handler", () => {
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
// Dynamically import to get fresh module with the mock
const { setupTerminalWebSocket } = await import("./server.js");
const app = express();
@@ -4973,12 +4971,13 @@ describe("Terminal WebSocket close handler", () => {
ws.close();
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
// The session must NOT be killed on WebSocket close
expect(killSessionMock).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it("kills PTY session when WebSocket encounters an error", async () => {
it("does NOT kill PTY session when WebSocket encounters an error (session persists for reconnect)", async () => {
const killSessionMock = vi.fn().mockReturnValue(true);
const getSessionMock = vi.fn().mockReturnValue({
id: "term-ws-err",
@@ -5024,7 +5023,67 @@ describe("Terminal WebSocket close handler", () => {
ws.emit("error", new Error("synthetic websocket failure"));
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
// The session must NOT be killed on WebSocket error
expect(killSessionMock).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it("cleans up data/exit subscriptions on WebSocket close without killing session", async () => {
// Verify that WebSocket close properly unsubscribes from terminal service
// events without destroying the underlying PTY session.
const killSessionMock = vi.fn().mockReturnValue(true);
const dataUnsub = vi.fn();
const exitUnsub = vi.fn();
const getSessionMock = vi.fn().mockReturnValue({
id: "term-ws-unsub",
shell: "/bin/zsh",
cwd: "/test/project",
});
const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null);
const onDataMock = vi.fn().mockReturnValue(dataUnsub);
const onExitMock = vi.fn().mockReturnValue(exitUnsub);
const mockService = {
getSession: getSessionMock,
getScrollbackAndClearPending: getScrollbackAndClearPendingMock,
killSession: killSessionMock,
write: vi.fn(),
resize: vi.fn(),
onData: onDataMock,
onExit: onExitMock,
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const { setupTerminalWebSocket } = await import("./server.js");
const app = express();
const server = http.createServer(app);
setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket();
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
expect(wss).toBeTruthy();
wss!.emit("connection", ws, {
url: "/api/terminal/ws?sessionId=term-ws-unsub",
headers: { host: "127.0.0.1" },
});
ws.close();
// Subscriptions should be cleaned up
expect(dataUnsub).toHaveBeenCalled();
expect(exitUnsub).toHaveBeenCalled();
// But session should NOT be killed
expect(killSessionMock).not.toHaveBeenCalled();
vi.restoreAllMocks();
});

View File

@@ -461,12 +461,10 @@ export function setupTerminalWebSocket(
clearInterval(pingInterval);
if (dataUnsub) dataUnsub();
if (exitUnsub) exitUnsub();
// Kill the PTY session to prevent session leaks
try {
terminalService.killSession(sessionId);
} catch {
// Ignore errors during cleanup — session may already be dead
}
// Do NOT kill the PTY session on WebSocket close — the session should
// survive transient disconnects and modal close/reopen cycles. Sessions
// are cleaned up through explicit kill paths (tab close, restart, shell
// exit) or stale-session eviction.
});
ws.on("error", () => {
@@ -474,15 +472,28 @@ export function setupTerminalWebSocket(
clearInterval(pingInterval);
if (dataUnsub) dataUnsub();
if (exitUnsub) exitUnsub();
// Kill the PTY session to prevent session leaks
try {
terminalService.killSession(sessionId);
} catch {
// Ignore errors during cleanup — session may already be dead
}
// Do NOT kill the PTY session on WebSocket error — same rationale as
// close: the session should persist for reconnection attempts.
});
});
// Periodic stale-session eviction (every 60 s) so that PTY sessions are
// eventually cleaned up when clients disconnect permanently without going
// through explicit kill paths. The eviction threshold is defined by
// TerminalService (default 5 minutes of inactivity).
const staleEvictionInterval = setInterval(() => {
try {
terminalService.evictStaleSessions();
} catch {
// Ignore errors during periodic eviction
}
}, 60_000);
// Stop eviction timer when the server shuts down
server.once("close", () => {
clearInterval(staleEvictionInterval);
});
console.log("Terminal WebSocket server mounted at /api/terminal/ws");
}