/** * T13 tests — session/manager.ts (session table lifecycle). * * ARCHITECTURE §3.5 + the cross-validated fixes: * - M3: reapIdle uses now - max(detachedAt, lastOutputAt) > idleTtlMs * (not just detachedAt, so active sessions with recent output are not reaped). * - M4: createSession throwing propagates out of handleAttach (not swallowed). * - L1: attach hitting an already-exited session → replay buffer + resend exit + remove. * - L2: the onExit callback injected into createSession removes the session from the table. * * node-pty is mocked so spawn returns a MockIPty — no real PTY/shell is started. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Config, Dims, NotifyService, Session, StatusTelemetry, WebSocketLike, } from '../src/types.js'; import { WS_OPEN } from '../src/types.js'; import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; // ── node-pty mock ─────────────────────────────────────────────────────────── // All createSession calls ultimately call spawn; we intercept at the node-pty // boundary so the session module works exactly as in production, but no real // shell is ever spawned. let nextPty: MockIPty = createMockPty(); let spawnShouldThrow: Error | null = null; vi.mock('node-pty', () => ({ spawn: (_file: string, _args: string[], _options: unknown) => { if (spawnShouldThrow) throw spawnShouldThrow; return nextPty; }, })); // Import AFTER vi.mock so the module under test binds to the mocked spawn. const { createSessionManager } = await import('../src/session/manager.js'); // ── Helpers ────────────────────────────────────────────────────────────────── const CFG: Config = { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 10_000, // 10 s for easy arithmetic in tests scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', maxSessions: 50, maxMsgsPerSec: 2000, permTimeoutMs: 300_000, reapIntervalMs: 60_000, previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], // v0.6 project manager projectRoots: ['/home/tester'], projectScanDepth: 4, projectScanTtlMs: 10_000, projectDirtyCheck: true, editorCmd: 'code', // v0.7 Walk-away Workbench vapidPublicKey: undefined, vapidPrivateKey: undefined, vapidSubject: undefined, pushStorePath: '/home/tester/.web-terminal-push-subs.json', pushMaxSubs: 50, notifyDone: true, notifyDnd: false, decisionTokenTtlMs: 300_000, timelineMax: 200, timelineEnabled: true, stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests stuckAlert: true, diffTimeoutMs: 2000, diffMaxBytes: 2 * 1024 * 1024, diffMaxFiles: 300, statuslineTtlMs: 30_000, worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, defaultPermissionMode: 'default', allowAutoMode: false, // W2 inject queue queueEnabled: true, queueMaxItems: 10, queueItemMaxBytes: 4096, queueSettleMs: 1500, }; const DIMS: Dims = { cols: 80, rows: 24 }; /** A telemetry sample for B2 tests. */ function makeTelemetry(at = 1_000): StatusTelemetry { return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at }; } /** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */ function createMockNotify() { const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {}); const service: NotifyService = { isEnabled: () => true, notify, }; return { service, notify }; } interface MockWs extends WebSocketLike { readyState: number; sent: string[]; closed: boolean; } function createMockWs(readyState: number = WS_OPEN): MockWs { const sent: string[] = []; return { readyState, sent, closed: false, send(data: string) { sent.push(data); }, close() { this.closed = true; this.readyState = 3; // CLOSED }, }; } function parseSent(ws: MockWs) { return ws.sent.map((s) => JSON.parse(s)); } beforeEach(() => { nextPty = createMockPty(); spawnShouldThrow = null; }); // ── handleAttach: null sessionId → new session ──────────────────────────────── describe('handleAttach — null sessionId', () => { it('creates a new session and returns it', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); expect(session).toBeDefined(); expect(session.meta.id).toBeTruthy(); expect(session.clients.has(ws)).toBe(true); }); it('stores the session so get() returns it', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); expect(mgr.get(session.meta.id)).toBe(session); }); it('propagates spawn failure without swallowing it (M4)', () => { spawnShouldThrow = new Error('spawn failed: /no/such/shell ENOENT'); const mgr = createSessionManager(CFG); const ws = createMockWs(); expect(() => mgr.handleAttach(ws, null, DIMS, 1_000)).toThrow(/spawn failed/); }); }); // ── handleAttach: hit live session ──────────────────────────────────────────── describe('handleAttach — hit live session', () => { it('JOINS the new ws and returns the same session (multi-device share)', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const id = session.meta.id; const ws2 = createMockWs(); const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000); expect(session2).toBe(session); // Both clients share the session now. expect(session.clients.has(ws1)).toBe(true); expect(session.clients.has(ws2)).toBe(true); }); it('does NOT kick the previously attached ws (v0.4 sharing)', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const id = session.meta.id; const ws2 = createMockWs(); mgr.handleAttach(ws2, id, DIMS, 2_000); // The old ws must stay open — devices mirror the session. expect(ws1.closed).toBe(false); expect(session.clients.size).toBe(2); }); it('broadcasts live output to ALL joined clients', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const ws2 = createMockWs(); mgr.handleAttach(ws2, session.meta.id, DIMS, 2_000); ws1.sent.length = 0; ws2.sent.length = 0; (session.pty as MockIPty).emitData('to everyone'); expect(parseSent(ws1).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true); expect(parseSent(ws2).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true); }); it('replays the buffer to the new ws', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const id = session.meta.id; // Produce some output while ws1 is attached. (session.pty as MockIPty).emitData('hello world'); const ws2 = createMockWs(); mgr.handleAttach(ws2, id, DIMS, 2_000); const msgs = parseSent(ws2); const outputMsgs = msgs.filter((m) => m.type === 'output'); expect(outputMsgs.some((m: { data: string }) => m.data.includes('hello world'))).toBe(true); }); }); // ── handleAttach: hit already-exited session (L1) ───────────────────────────── describe('handleAttach — hit already-exited session (L1)', () => { it('sends the buffer snapshot to the new ws', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const id = session.meta.id; // Build some output, then detach ws1, then simulate PTY exit. (session.pty as MockIPty).emitData('last output before exit'); mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach // Force detach by patching directly (simulates WS close event). session.clients.clear(); session.detachedAt = 3_000; // Now PTY exits while detached — session preserved (L1). (session.pty as MockIPty).emitExit(0); // session.exitedAt is set, session still in table because onExit in detach // scenario keeps session (manager's onExit callback removes from table only if // invoked — but per L2, after exit while attached ws exists the callback removes, // while L1 says detach-then-exit: we must keep for replay). // Actually per ARCHITECTURE: the injected onExit IS called by session.ts after exit. // But the L1 scenario means: exitedAt!=null, session is in table until re-attach. // We re-insert it manually to simulate the correct state from ARCHITECTURE §4.4: // "session is preserved (buffer + exitedAt)" waiting for re-attach. // The key thing to test is that when a NEW ws comes with this session's id, // the manager replays the buffer + sends exit + removes from table. // For this test: we need the session to still be in the map with exitedAt set. // The manager's injected onExit should NOT remove from table in this case — // But wait: per ARCHITECTURE §3.5 L2: "PTY 退出: injected onExit → 从表删除" // And L1: "detach后exit → 保留buffer+exitedAt, 等用户回来attach" // These seem contradictory. Let me re-read: // // ARCHITECTURE §3.5 constraint: "PTY 退出: createSession 内部 onExit → 置 exitedAt → // 有 ws 则发 exit → 调 manager 注入的回调从表移除 (L2)" // ARCHITECTURE §3.5 L1: "detach 后才退出(无 ws 可通知, L1): 不立即丢弃会话, 保留 // buffer + exitedAt, 等用户带旧 id 回来 attach 时回放 + 补发 exit" // // Resolution: L2 says "从表移除" — but L1 says "不立即丢弃". So L2 applies when // attachedWs is NOT null (ws is attached), and L1 applies when detached. // The manager's injected onExit callback should: // - Only remove from table if attachedWs != null at exit time (ws was attached) // - Keep in table if detached at exit time (for L1 replay on reconnect) // // But PLAN.md T13 Steps says: "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)" // And the task prompt says: "L2: onExit 从会话表删除" // And also: "命中已退出会话(exitedAt!=null) → 回放+补发exit后从表移除(L1)" // // So the behavior is: // - When PTY exits with ws attached: onExit removes from table immediately (L2). // - When PTY exits while detached (L1): onExit does NOT remove; session stays. // The session is removed when the next attach hits it and sees exitedAt!=null. // // This means the manager's onExit callback must check attachedWs: if null, keep. // If ws attached, remove. // // Actually re-reading ARCHITECTURE §3.5 more carefully: // The constraint says: "PTY 退出: session 内部 onExit → 置 exitedAt → 有 ws 则发 exit // → 调 manager 注入的回调从表移除 (L2: session 不持有 manager, 退出事件靠注入回调跨层冒泡)" // // This says "调 manager 注入的回调从表移除" — it calls the callback and removes. // But L1 says "不立即丢弃". These are the same callback situation. // // Looking at PLAN T13 instruction: "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)" // And the task prompt: "命中已退出 → 回放+补发exit+移除 → 从表移除(L1)" // // The reconciliation: L2's callback ALWAYS removes the session from the table when // called. L1 means: when the session exits while detached, the session stays in the // table BECAUSE the onExit in the detach case should NOT remove it immediately. // // Wait, but the task prompt says "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)" // unconditionally. And L1 says "保留 buffer + exitedAt". // // The only way to reconcile: the onExit callback should NOT remove when detached. // The callback should check session.attachedWs (which is null when detached): // if (session.attachedWs !== null) → remove from table (session was live) // if (session.attachedWs === null) → keep in table (for L1 replay) // // This matches ARCHITECTURE §4.4: "[在 detach 状态下退出] pty.onExit → 置 exitedAt/exitCode // → attachedWs 为 null, exit 暂不投递 → 会话保留(buffer + exitedAt)" // And the session.ts onExit flow: sets exitedAt, sends exit if ws, calls manager onExit. // // So the manager's onExit MUST only remove when ws was attached. When detached, // it should keep the session for L1 replay. // // CONCLUSION: The injected onExit callback in the manager checks session.attachedWs: // - non-null → ws existed when exit fired → remove from table (L2) // - null → detached-then-exit → keep in table for L1 replay // At this point, session.exitedAt should be set, and session should still be in table. expect(session.exitedAt).not.toBeNull(); // Now a new ws comes back with the same session id: const ws2 = createMockWs(); // Need to re-add session to map manually IF the onExit removed it. // Let's just test the flow by checking what actually happens: const result = mgr.handleAttach(ws2, id, DIMS, 5_000); // The manager should have replayed the buffer to ws2. const msgs = parseSent(ws2); const outputMsgs = msgs.filter((m: { type: string }) => m.type === 'output'); expect(outputMsgs.some((m: { data: string }) => m.data.includes('last output before exit'))).toBe(true); }); it('sends exit message to the new ws after replay (L1)', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const id = session.meta.id; // Detach by simulating WS close. session.clients.clear(); session.detachedAt = 2_000; // PTY exits while detached. (session.pty as MockIPty).emitExit(42); expect(session.exitedAt).not.toBeNull(); expect(session.exitCode).toBe(42); // New ws comes back. const ws2 = createMockWs(); mgr.handleAttach(ws2, id, DIMS, 5_000); const msgs = parseSent(ws2); const exitMsgs = msgs.filter((m: { type: string }) => m.type === 'exit'); expect(exitMsgs).toHaveLength(1); expect(exitMsgs[0]).toMatchObject({ type: 'exit', code: 42 }); }); it('removes the exited session from the table after replay (L1)', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); const id = session.meta.id; // Detach and exit. session.clients.clear(); session.detachedAt = 2_000; (session.pty as MockIPty).emitExit(0); // Reconnect triggers L1 replay + removal. const ws2 = createMockWs(); mgr.handleAttach(ws2, id, DIMS, 5_000); // Session must be gone from the table now. expect(mgr.get(id)).toBeUndefined(); }); }); // ── handleAttach: session not found → new session ───────────────────────────── describe('handleAttach — session not found', () => { it('creates a new session when the given id does not exist', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const unknownId = '00000000-0000-4000-8000-000000000000'; const session = mgr.handleAttach(ws, unknownId, DIMS, 1_000); expect(session).toBeDefined(); // The returned session has a NEW id (not the bogus one). expect(session.meta.id).not.toBe(unknownId); expect(session.clients.has(ws)).toBe(true); }); it('the newly created session is stored in the table', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const unknownId = '00000000-0000-4000-8000-000000000001'; const session = mgr.handleAttach(ws, unknownId, DIMS, 1_000); expect(mgr.get(session.meta.id)).toBe(session); // Old unknown id is NOT stored. expect(mgr.get(unknownId)).toBeUndefined(); }); }); // ── get ─────────────────────────────────────────────────────────────────────── describe('get', () => { it('returns undefined for unknown ids', () => { const mgr = createSessionManager(CFG); expect(mgr.get('00000000-0000-4000-8000-000000000002')).toBeUndefined(); }); it('returns the session for a known id', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); expect(mgr.get(session.meta.id)).toBe(session); }); }); // ── reapIdle (M3) ───────────────────────────────────────────────────────────── describe('reapIdle (M3)', () => { it('does not reap a session that is still attached', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); // Not detached, so detachedAt = null → not eligible. const reaped = mgr.reapIdle(1_000 + CFG.idleTtlMs + 1); expect(reaped).toBe(0); expect(mgr.get(session.meta.id)).toBeDefined(); }); it('does not reap a detached session that received output recently (M3)', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); const id = session.meta.id; // Detach at t=2000. session.clients.clear(); session.detachedAt = 2_000; // Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt. (session.pty as MockIPty).emitData('fresh output'); // Force the lastOutputAt to a specific value we can assert against. session.lastOutputAt = 5_000; // Now reap at t = 5000 + idleTtl - 1 → not yet expired relative to lastOutputAt. const reaped = mgr.reapIdle(5_000 + CFG.idleTtlMs - 1); expect(reaped).toBe(0); expect(mgr.get(id)).toBeDefined(); expect((session.pty as MockIPty).killed).toBe(false); }); it('reaps a detached session whose max(detachedAt, lastOutputAt) + idleTtl has passed (M3)', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); const id = session.meta.id; // Detach at t=2000; no output after detach. session.clients.clear(); session.detachedAt = 2_000; session.lastOutputAt = 1_500; // before detach → max = 2000 // Reap at t = 2000 + idleTtl + 1 → expired. const reaped = mgr.reapIdle(2_000 + CFG.idleTtlMs + 1); expect(reaped).toBe(1); expect(mgr.get(id)).toBeUndefined(); expect((session.pty as MockIPty).killed).toBe(true); }); it('reaps multiple idle sessions and returns the count', () => { const mgr = createSessionManager(CFG); // Session A — will be reaped. nextPty = createMockPty(); const wsA = createMockWs(); const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000); sessionA.clients.clear(); sessionA.detachedAt = 1_000; sessionA.lastOutputAt = 1_000; // Session B — will be reaped. nextPty = createMockPty(); const wsB = createMockWs(); const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000); sessionB.clients.clear(); sessionB.detachedAt = 1_000; sessionB.lastOutputAt = 1_000; const reaped = mgr.reapIdle(1_000 + CFG.idleTtlMs + 1); expect(reaped).toBe(2); expect(mgr.get(sessionA.meta.id)).toBeUndefined(); expect(mgr.get(sessionB.meta.id)).toBeUndefined(); }); it('does not reap if detachedAt is null (still attached / never detached)', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); // detachedAt is null — session is still live. const reaped = mgr.reapIdle(99_999_999); expect(reaped).toBe(0); expect(mgr.get(session.meta.id)).toBeDefined(); }); }); // ── list (v0.4 multi-device discovery) ──────────────────────────────────────── describe('list', () => { it('returns live sessions with client counts, newest first', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const a = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); nextPty = createMockPty(); const b = mgr.handleAttach(createMockWs(), null, DIMS, 2_000); // a second device joins session b → clientCount 2 mgr.handleAttach(createMockWs(), b.meta.id, DIMS, 2_500); const list = mgr.list(); expect(list.map((s) => s.id)).toEqual([b.meta.id, a.meta.id]); // newest first expect(list.find((s) => s.id === b.meta.id)?.clientCount).toBe(2); expect(list.find((s) => s.id === a.meta.id)?.clientCount).toBe(1); expect(list.every((s) => s.exited === false)).toBe(true); }); it('returns an empty array when there are no sessions', () => { const mgr = createSessionManager(CFG); expect(mgr.list()).toEqual([]); }); }); // ── shutdown ────────────────────────────────────────────────────────────────── describe('shutdown', () => { it('kills all sessions and clears the table', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const ws1 = createMockWs(); const s1 = mgr.handleAttach(ws1, null, DIMS, 1_000); nextPty = createMockPty(); const ws2 = createMockWs(); const s2 = mgr.handleAttach(ws2, null, DIMS, 1_000); mgr.shutdown(); expect((s1.pty as MockIPty).killed).toBe(true); expect((s2.pty as MockIPty).killed).toBe(true); // Table is cleared. expect(mgr.get(s1.meta.id)).toBeUndefined(); expect(mgr.get(s2.meta.id)).toBeUndefined(); }); }); // ── maxSessions DoS cap (Sec H1) ────────────────────────────────────────────── describe('handleAttach — session cap', () => { it('throws once the table is at cfg.maxSessions (new-session path)', () => { const cappedCfg: Config = { ...CFG, maxSessions: 2 }; const mgr = createSessionManager(cappedCfg); nextPty = createMockPty(); mgr.handleAttach(createMockWs(), null, DIMS, 1_000); nextPty = createMockPty(); mgr.handleAttach(createMockWs(), null, DIMS, 1_000); // Third new session must be refused (reuses the M4 exit(-1) path in server). nextPty = createMockPty(); expect(() => mgr.handleAttach(createMockWs(), null, DIMS, 1_000)).toThrow(/limit/i); }); it('also caps the not-found path (unknown id → would create)', () => { const cappedCfg: Config = { ...CFG, maxSessions: 1 }; const mgr = createSessionManager(cappedCfg); mgr.handleAttach(createMockWs(), null, DIMS, 1_000); const unknown = '00000000-0000-4000-8000-000000000099'; nextPty = createMockPty(); expect(() => mgr.handleAttach(createMockWs(), unknown, DIMS, 1_000)).toThrow(/limit/i); }); it('JOINing an existing session does NOT count against the cap', () => { const cappedCfg: Config = { ...CFG, maxSessions: 1 }; const mgr = createSessionManager(cappedCfg); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); // A second device joining the same session must be allowed at the cap. expect(() => mgr.handleAttach(createMockWs(), s.meta.id, DIMS, 2_000)).not.toThrow(); expect(s.clients.size).toBe(2); }); }); // ── killById (manage page) ──────────────────────────────────────────────────── describe('killById', () => { it('closes all clients, kills the PTY, and removes the session', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const b = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); const ok = mgr.killById(s.meta.id); expect(ok).toBe(true); expect(a.closed).toBe(true); expect(b.closed).toBe(true); expect((s.pty as MockIPty).killed).toBe(true); expect(mgr.get(s.meta.id)).toBeUndefined(); }); it('returns false for an unknown id', () => { const mgr = createSessionManager(CFG); expect(mgr.killById('00000000-0000-4000-8000-0000000000aa')).toBe(false); }); }); // ── handleHookEvent (H2/H3 status push) ─────────────────────────────────────── describe('handleHookEvent', () => { it('sets claudeStatus and broadcasts a status frame to all clients', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const b = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); a.sent.length = 0; b.sent.length = 0; mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true); expect(s.claudeStatus).toBe('waiting'); for (const ws of [a, b]) { const status = parseSent(ws).find((m) => m.type === 'status'); expect(status).toMatchObject({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }); } }); it('omits detail/pending when not supplied', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); ws.sent.length = 0; mgr.handleHookEvent(s.meta.id, 'idle'); const status = parseSent(ws).find((m) => m.type === 'status'); expect(status).toEqual({ type: 'status', status: 'idle' }); }); it('is a no-op for an unknown session id', () => { const mgr = createSessionManager(CFG); expect(() => mgr.handleHookEvent('00000000-0000-4000-8000-0000000000bb', 'working')).not.toThrow(); }); }); // ── L2: injected onExit removes session when ws is attached at exit time ─────── describe('onExit removes session from table when ws attached at exit time (L2)', () => { it('session is removed from the table when PTY exits while ws is attached', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); const id = session.meta.id; // PTY exits while ws is still attached. (session.pty as MockIPty).emitExit(0); // The manager's onExit should have removed the session. expect(mgr.get(id)).toBeUndefined(); }); it('session is NOT removed from table when PTY exits while detached (kept for L1 replay)', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const session = mgr.handleAttach(ws, null, DIMS, 1_000); const id = session.meta.id; // Detach first. session.clients.clear(); session.detachedAt = 2_000; // Then PTY exits. (session.pty as MockIPty).emitExit(0); // Session must stay in table for L1 replay. expect(mgr.get(id)).toBeDefined(); expect(session.exitedAt).not.toBeNull(); }); }); // ── handleHookEvent — A4 timeline + B4 gate (T-manager) ─────────────────────── describe('handleHookEvent — timeline append (A4)', () => { it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash'); expect(s.timeline).toHaveLength(1); expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' }); expect(typeof s.timeline[0]?.at).toBe('number'); }); it('replaces the timeline array immutably (never mutates the previous one)', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); const before = s.timeline; mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash'); expect(s.timeline).not.toBe(before); expect(before).toHaveLength(0); }); it('does NOT append when timeline is disabled', () => { const mgr = createSessionManager({ ...CFG, timelineEnabled: false }); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash'); expect(s.timeline).toHaveLength(0); }); it('does NOT append when eventClass is omitted', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true); expect(s.timeline).toHaveLength(0); }); it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X'); expect(s.timeline).toHaveLength(0); }); it('evicts oldest events past cfg.timelineMax', () => { const mgr = createSessionManager({ ...CFG, timelineMax: 3 }); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); for (let i = 0; i < 5; i += 1) { mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`); } expect(s.timeline).toHaveLength(3); expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']); }); }); describe('handleHookEvent — gate broadcast (B4)', () => { it('broadcasts the status frame with gate when supplied', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); ws.sent.length = 0; mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode'); const status = parseSent(ws).find((m) => m.type === 'status'); expect(status).toMatchObject({ type: 'status', status: 'waiting', detail: 'ExitPlanMode', pending: true, gate: 'plan', }); }); it('omits gate when not supplied', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); ws.sent.length = 0; mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true); const status = parseSent(ws).find((m) => m.type === 'status'); expect(status).not.toHaveProperty('gate'); }); }); // ── handleHookEvent — W1 preview broadcast ──────────────────────────────────── describe('handleHookEvent — preview broadcast (W1)', () => { it('puts the preview arg on the broadcast status frame (deep equal)', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const b = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); a.sent.length = 0; b.sent.length = 0; const preview = { kind: 'command', text: 'ls -la' } as const; mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true, 'tool', undefined, undefined, preview); for (const ws of [a, b]) { const status = parseSent(ws).find((m) => m.type === 'status'); expect(status.preview).toEqual(preview); } }); it('omits the preview key when no preview is supplied', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); ws.sent.length = 0; mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true, 'tool'); const status = parseSent(ws).find((m) => m.type === 'status'); expect(status).not.toHaveProperty('preview'); }); }); // ── handleStatusLine (B2 telemetry) ─────────────────────────────────────────── describe('handleStatusLine', () => { it('stores telemetry on the session and broadcasts it to all clients', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const b = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); a.sent.length = 0; b.sent.length = 0; const telemetry = makeTelemetry(); mgr.handleStatusLine(s.meta.id, telemetry); expect(s.telemetry).toBe(telemetry); for (const ws of [a, b]) { const msg = parseSent(ws).find((m) => m.type === 'telemetry'); expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } }); } }); it('is a no-op for an unknown session id', () => { const mgr = createSessionManager(CFG); expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow(); }); }); // ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ───── describe('handleAttach — late-join replay (M3)', () => { it('sends the current telemetry to a device joining a live session', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); mgr.handleStatusLine(s.meta.id, makeTelemetry()); const b = createMockWs(); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); const msg = parseSent(b).find((m) => m.type === 'telemetry'); expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } }); }); it('sends the current status to a late-joining device', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); mgr.handleHookEvent(s.meta.id, 'working'); const b = createMockWs(); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); const status = parseSent(b).find((m) => m.type === 'status'); expect(status).toMatchObject({ type: 'status', status: 'working' }); }); it('does NOT send a telemetry frame when the session has no telemetry yet', () => { const mgr = createSessionManager(CFG); const a = createMockWs(); const s = mgr.handleAttach(a, null, DIMS, 1_000); const b = createMockWs(); mgr.handleAttach(b, s.meta.id, DIMS, 2_000); expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false); }); }); // ── sweepStuck (A5) ─────────────────────────────────────────────────────────── describe('sweepStuck (A5)', () => { it('marks a silent live session stuck, broadcasts, and notifies once', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000 ws.sent.length = 0; mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); expect(s.claudeStatus).toBe('stuck'); expect(s.stuckNotified).toBe(true); const status = parseSent(ws).find((m) => m.type === 'status'); expect(status).toMatchObject({ type: 'status', status: 'stuck' }); expect(notify).toHaveBeenCalledTimes(1); expect(notify).toHaveBeenCalledWith(s, 'stuck'); }); it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); // still attached: clients.size === 1, detachedAt === null mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); expect(s.detachedAt).toBeNull(); expect(s.claudeStatus).toBe('stuck'); expect(notify).toHaveBeenCalledTimes(1); }); it('alerts at most once per silent round (AC-A5.1)', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999); expect(notify).toHaveBeenCalledTimes(1); expect(s.claudeStatus).toBe('stuck'); }); it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); expect(notify).toHaveBeenCalledTimes(1); // Output recovery (session.ts onData) re-arms the flag + status. (s.pty as MockIPty).emitData('back to work'); expect(s.stuckNotified).toBe(false); expect(s.claudeStatus).toBe('working'); s.lastOutputAt = 100_000; // pin to a known cursor mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1); expect(notify).toHaveBeenCalledTimes(2); expect(s.claudeStatus).toBe('stuck'); }); it('skips idle sessions', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); s.claudeStatus = 'idle'; mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); expect(s.claudeStatus).toBe('idle'); expect(notify).not.toHaveBeenCalled(); }); it('skips already-exited sessions (kept in table after detach-then-exit)', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); s.clients.clear(); s.detachedAt = 2_000; (s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1) mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); expect(s.claudeStatus).not.toBe('stuck'); expect(notify).not.toHaveBeenCalled(); }); it('does not mark a session whose output is within the TTL window', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1); expect(s.claudeStatus).not.toBe('stuck'); expect(notify).not.toHaveBeenCalled(); }); it('is disabled when stuckAlert is false', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); expect(s.claudeStatus).not.toBe('stuck'); expect(notify).not.toHaveBeenCalled(); }); it('is disabled when stuckTtlMs is 0', () => { const { service, notify } = createMockNotify(); const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.sweepStuck(99_999_999); expect(s.claudeStatus).not.toBe('stuck'); expect(notify).not.toHaveBeenCalled(); }); it('swallows a notifyService rejection (logs via console.error, never throws)', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const notify = vi.fn(async () => { throw new Error('push transport down'); }); const service: NotifyService = { isEnabled: () => true, notify }; const mgr = createSessionManager(CFG, service); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow(); await Promise.resolve(); await Promise.resolve(); expect(s.claudeStatus).toBe('stuck'); expect(errSpy).toHaveBeenCalled(); errSpy.mockRestore(); }); it('works without an injected notifyService (broadcasts, no throw)', () => { const mgr = createSessionManager(CFG); // no notifyService const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); ws.sent.length = 0; expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow(); expect(s.claudeStatus).toBe('stuck'); expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true); }); }); // ── list() carries telemetry (B2 thumbnail wall) ────────────────────────────── describe('list — telemetry field', () => { it('includes the latest telemetry for each session', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); const telemetry = makeTelemetry(); mgr.handleStatusLine(s.meta.id, telemetry); const entry = mgr.list().find((e) => e.id === s.meta.id); expect(entry?.telemetry).toBe(telemetry); }); it('reports null telemetry before the first statusLine report', () => { const mgr = createSessionManager(CFG); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); const entry = mgr.list().find((e) => e.id === s.meta.id); expect(entry?.telemetry).toBeNull(); }); }); // ── list() carries lastOutputAt (T-iOS-37 unread watermark) ─────────────────── // The runtime record (Session.lastOutputAt, refreshed on every pty.onData — M3) // is serialized into LiveSessionInfo so clients can compute an unread dot // (lastOutputAt > local last-seen). Additive OPTIONAL field: existing consumers // (preview grid, projects panel) are unaffected. describe('list — lastOutputAt field (T-iOS-37)', () => { it('includes lastOutputAt matching the session runtime record after output flows', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); // Output flows → session.ts pty.onData refreshes the runtime record with // Date.now(); list() must serialize exactly that value. const before = Date.now(); (s.pty as MockIPty).emitData('hello from claude'); const entry = mgr.list().find((e) => e.id === s.meta.id); expect(s.lastOutputAt).toBeGreaterThanOrEqual(before); expect(entry?.lastOutputAt).toBe(s.lastOutputAt); }); it('serializes a pinned runtime value verbatim (epoch ms, no transformation)', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); // Pin the runtime record to a known cursor (same style as the M3 reap tests). s.lastOutputAt = 123_456; const entry = mgr.list().find((e) => e.id === s.meta.id); expect(entry?.lastOutputAt).toBe(123_456); }); it('equals spawn time for a fresh session before any output (createSession inits lastOutputAt = now)', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); // createSession initializes lastOutputAt: now (src/session/session.ts) — // a fresh session's watermark IS its spawn time, never absent/zero. const entry = mgr.list().find((e) => e.id === s.meta.id); expect(entry?.lastOutputAt).toBe(1_000); }); it('keeps the existing LiveSessionInfo shape intact (additive optional field)', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); const entry = mgr.list().find((e) => e.id === s.meta.id); expect(entry).toMatchObject({ id: s.meta.id, createdAt: 1_000, clientCount: 1, status: 'unknown', exited: false, cols: 80, rows: 24, telemetry: null, }); }); }); // ── W2: inject-queue methods (enqueueFollowup / drainOne / clearQueue) ──────── describe('W2 inject queue — enqueueFollowup', () => { it('appends and returns {ok,length}, broadcasting {type:queue,length} to clients', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); ws.sent.length = 0; const r1 = mgr.enqueueFollowup(s.meta.id, 'echo one\r'); expect(r1).toEqual({ ok: true, length: 1 }); expect(s.queue).toEqual(['echo one\r']); expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true); const r2 = mgr.enqueueFollowup(s.meta.id, 'echo two\r'); expect(r2).toEqual({ ok: true, length: 2 }); expect(s.queue).toEqual(['echo one\r', 'echo two\r']); }); it('caps at queueMaxItems → {ok:false,reason:full}, no broadcast, queue unchanged', () => { const mgr = createSessionManager({ ...CFG, queueMaxItems: 2 }); nextPty = createMockPty(); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.enqueueFollowup(s.meta.id, 'a'); mgr.enqueueFollowup(s.meta.id, 'b'); ws.sent.length = 0; const r = mgr.enqueueFollowup(s.meta.id, 'c'); expect(r).toEqual({ ok: false, reason: 'full' }); expect(s.queue).toEqual(['a', 'b']); // immutable: unchanged expect(parseSent(ws).some((m) => m.type === 'queue')).toBe(false); // no broadcast }); it('unknown id → {ok:false,reason:unknown}', () => { const mgr = createSessionManager(CFG); expect(mgr.enqueueFollowup('11111111-1111-4111-8111-111111111111', 'x')).toEqual({ ok: false, reason: 'unknown', }); }); it('exited session → {ok:false,reason:exited}', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); s.exitedAt = 2_000; expect(mgr.enqueueFollowup(s.meta.id, 'x')).toEqual({ ok: false, reason: 'exited' }); }); }); describe('W2 inject queue — drainOne', () => { it('pops the head, writes it to the PTY, shrinks the queue, and broadcasts depth', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.enqueueFollowup(s.meta.id, 'HEAD\r'); mgr.enqueueFollowup(s.meta.id, 'TAIL\r'); ws.sent.length = 0; const pty = s.pty as MockIPty; pty.writes.length = 0; const drained = mgr.drainOne(s.meta.id); expect(drained).toBe('HEAD\r'); expect(pty.writes).toEqual(['HEAD\r']); // byte-identical inject expect(s.queue).toEqual(['TAIL\r']); // length now 1 expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true); }); it('empty queue → null, no PTY write', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); const pty = s.pty as MockIPty; pty.writes.length = 0; expect(mgr.drainOne(s.meta.id)).toBeNull(); expect(pty.writes).toEqual([]); }); it('exited session with items queued → null (double-guards L4), no write', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); mgr.enqueueFollowup(s.meta.id, 'x'); s.exitedAt = 2_000; const pty = s.pty as MockIPty; pty.writes.length = 0; expect(mgr.drainOne(s.meta.id)).toBeNull(); expect(pty.writes).toEqual([]); }); it('unknown id → null', () => { const mgr = createSessionManager(CFG); expect(mgr.drainOne('11111111-1111-4111-8111-111111111111')).toBeNull(); }); }); describe('W2 inject queue — clearQueue', () => { it('empties the queue, broadcasts length 0, returns true', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const ws = createMockWs(); const s = mgr.handleAttach(ws, null, DIMS, 1_000); mgr.enqueueFollowup(s.meta.id, 'a'); mgr.enqueueFollowup(s.meta.id, 'b'); ws.sent.length = 0; expect(mgr.clearQueue(s.meta.id)).toBe(true); expect(s.queue).toEqual([]); expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 0)).toBe(true); }); it('unknown id → false', () => { const mgr = createSessionManager(CFG); expect(mgr.clearQueue('11111111-1111-4111-8111-111111111111')).toBe(false); }); }); describe('W2 inject queue — list() surfaces queueLength', () => { it('reports the pending depth per session', () => { const mgr = createSessionManager(CFG); nextPty = createMockPty(); const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); mgr.enqueueFollowup(s.meta.id, 'a'); mgr.enqueueFollowup(s.meta.id, 'b'); const entry = mgr.list().find((e) => e.id === s.meta.id); expect(entry?.queueLength).toBe(2); }); });