/** * AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager * smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit * tracking, and change-event subscription. The "AttachConsole failed" * console noise from node-pty's ConPTY module on Windows is expected and * does not affect the assertions. */ import { describe, expect, it, vi } from 'vitest' import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, tryResizePty, } from '../src/agent-pty.ts' import { SidebarError } from '../src/wire.ts' /** * Resolve a shell binary for tests: on Windows use PowerShell (available on * every modern Windows host), on POSIX use /bin/sh (universally available). */ function testShell(): string { return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh' } /** Wait for a terminal's transcript to contain a substring (or timeout). */ async function waitForTranscript( registry: AgentPtyRegistry, uuid: string, needle: string, timeoutMs = 15_000, ): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const handle = registry.get(uuid) if (handle !== undefined && handle.transcript.includes(needle)) return handle.transcript await new Promise(resolve => setTimeout(resolve, 50)) } const handle = registry.get(uuid) return handle?.transcript ?? '' } describe('tryResizePty', () => { it('clamps valid dimensions before resizing', () => { const resize = vi.fn() expect(tryResizePty({ resize }, 5000, 80.9)).toBe(true) expect(resize).toHaveBeenCalledWith(1024, 80) }) it('contains a node-pty resize failure', () => { const resize = vi.fn(() => { throw new Error('Usage: pty.resize(fd, cols, rows, xPixel, yPixel)') }) expect(tryResizePty({ resize }, 80, 24)).toBe(false) expect(resize).toHaveBeenCalledWith(80, 24) }) it.each([ [Number.NaN, 24], [Number.POSITIVE_INFINITY, 24], [80, Number.NEGATIVE_INFINITY], ])('rejects non-finite dimensions without calling node-pty (%s, %s)', (cols, rows) => { const resize = vi.fn() expect(tryResizePty({ resize }, cols, rows)).toBe(false) expect(resize).not.toHaveBeenCalled() }) }) /** * The registry suite really starts a shell per terminal — a PowerShell + * ConPTY pair on Windows, /bin/sh elsewhere. vitest's 5000 ms default is * below a cold PowerShell start on a loaded 2-core CI runner (a sibling spec * measured 12.1 s on 2026-09-10), which is what turned six ci-windows runs * red in the 2026-09-09/10 window. Generous, but still finite: a genuinely * hung spawn fails the case. */ describe('AgentPtyRegistry', { timeout: 30_000 }, () => { it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24) expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) const list = registry.list('s1') expect(list).toHaveLength(1) expect(list[0]!.uuid).toBe(uuid) expect(list[0]!.title).toBe('echo test') expect(list[0]!.command).toBe('echo hello-agent-pty') expect(list[0]!.exited).toBe(false) // The command was written to stdin; wait for the output. const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty') expect(transcript).toContain('hello-agent-pty') } finally { registry.disposeAll() } }) it('spawns a bare shell when command is empty', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24) const handle = registry.get(uuid) expect(handle).toBeDefined() expect(handle!.command).toBe('') expect(handle!.exited).toBe(false) // The pty process exists; its transcript may take a moment to // accumulate on Windows ConPTY. The handle is alive and listed. expect(registry.list('s1')).toHaveLength(1) } finally { registry.disposeAll() } }) it('sends raw text to stdin (send-keys semantics)', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24) // Wait for the shell prompt, then send a command. await waitForTranscript(registry, uuid, '', 1000) // The registry's send() writes verbatim; the caller (tool layer) is // responsible for appending \r when submit=true. Here we test the // registry directly: send text + \r to submit. registry.send(uuid, 'echo sent-via-send\r') const transcript = await waitForTranscript(registry, uuid, 'sent-via-send') expect(transcript).toContain('sent-via-send') } finally { registry.disposeAll() } }) it('reads a bounded page of the transcript', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'line3') const page = registry.read(uuid) expect(page.totalLines).toBeGreaterThan(0) expect(page.text).toContain('line1') expect(page.text).toContain('line3') // Negative offset reads from the end. const tail = registry.read(uuid, -2) expect(tail.lineBegin).toBeGreaterThanOrEqual(0) expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length) } finally { registry.disposeAll() } }) it('resizes without throwing', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24) expect(() => registry.resize(uuid, 120, 40)).not.toThrow() } finally { registry.disposeAll() } }) it('resize clamps to the 2..1024 range and returns the applied dimensions', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24) // Oversized / undersized requests report the clamped values, so the // echoed result always matches the pty (the tool contract). expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 }) expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 }) expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 }) // create clamps through the same helper. const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999) expect(registry.get(other)!.pty.cols).toBe(2) expect(registry.get(other)!.pty.rows).toBe(1024) } finally { registry.disposeAll() } }) it('assertOwned rejects a uuid owned by another session', () => { const registry = new AgentPtyRegistry(testShell()) try { const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24) const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24) expect(() => registry.assertOwned(mine, 's1')).not.toThrow() // A foreign uuid is indistinguishable from an unknown one. expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/) expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/) } finally { registry.disposeAll() } }) it('closes a terminal and removes it from the list', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24) expect(registry.list('s1')).toHaveLength(1) const closed = registry.close(uuid) expect(closed).toBe(true) expect(registry.list('s1')).toHaveLength(0) // Closing again is a no-op. const closedAgain = registry.close(uuid) expect(closedAgain).toBe(false) } finally { registry.disposeAll() } }) it('scopes list by session id', () => { const registry = new AgentPtyRegistry(testShell()) try { registry.create('s1', 'a', '', process.cwd(), 80, 24) registry.create('s1', 'b', '', process.cwd(), 80, 24) registry.create('s2', 'c', '', process.cwd(), 80, 24) expect(registry.list('s1')).toHaveLength(2) expect(registry.list('s2')).toHaveLength(1) expect(registry.list('s3')).toHaveLength(0) } finally { registry.disposeAll() } }) it('fires change listeners on create, close, and exit', async () => { const registry = new AgentPtyRegistry(testShell()) try { let changeCount = 0 registry.subscribe(() => { changeCount += 1 }) const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24) // create fires one change. expect(changeCount).toBeGreaterThanOrEqual(1) registry.close(uuid) // close fires another change. expect(changeCount).toBeGreaterThanOrEqual(2) } finally { registry.disposeAll() } }) it('disposeAll closes every terminal', () => { const registry = new AgentPtyRegistry(testShell()) registry.create('s1', 'a', '', process.cwd(), 80, 24) registry.create('s2', 'b', '', process.cwd(), 80, 24) registry.disposeAll() expect(registry.list('s1')).toHaveLength(0) expect(registry.list('s2')).toHaveLength(0) }) it('snapshotOf drops the pty reference and transcript', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24) const handle = registry.get(uuid)! const snap = snapshotOf(handle) expect(snap.uuid).toBe(uuid) expect(snap.title).toBe('snap') expect(snap.exited).toBe(false) // No pty or transcript fields on the snapshot. expect('pty' in snap).toBe(false) expect('transcript' in snap).toBe(false) // sessionId is registry-internal ownership, never serialized (it would // violate the terminal_list output schema's additionalProperties:false). expect('sessionId' in snap).toBe(false) expect('sessionId' in registry.list('s1')[0]!).toBe(false) } finally { registry.disposeAll() } }) it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => { expect(ALLOWED_SIGNALS).toContain('SIGINT') expect(ALLOWED_SIGNALS).toContain('SIGTERM') expect(ALLOWED_SIGNALS).toContain('SIGKILL') expect(ALLOWED_SIGNALS).toContain('SIGHUP') expect(ALLOWED_SIGNALS).toContain('SIGTSTP') }) it('waitFor returns found when the needle is already in the transcript', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'wait-for-fast') // The needle is already present; waitFor should return immediately. const result = await registry.waitFor(uuid, 'wait-for-fast', 2000) expect(result.kind).toBe('found') if (result.kind === 'found') { expect(result.needle).toBe('wait-for-fast') expect(result.elapsedMs).toBeLessThan(500) } } finally { registry.disposeAll() } }) it('waitFor supports regex alternation and reports which alternative matched', async () => { const registry = new AgentPtyRegistry(testShell()) try { // Multi-outcome pattern (build success/failure): the terminal prints // only the FAILURE marker; the alternation must match immediately and // the `match` field must tell WHICH alternative hit. const uuid = registry.create('s1', 'multi-outcome', 'echo BUILD_FAIL', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'BUILD_FAIL') const result = await registry.waitFor(uuid, 'BUILD_(OK|FAIL)', 2000) expect(result.kind).toBe('found') if (result.kind === 'found') { expect(result.needle).toBe('BUILD_(OK|FAIL)') expect(result.match).toBe('BUILD_FAIL') } } finally { registry.disposeAll() } }) it('waitFor falls back to verbatim matching when the pattern is not a valid regex', async () => { const registry = new AgentPtyRegistry(testShell()) try { // `BUILD_(OK` does not compile as a regex (unbalanced group); the wait // must degrade to literal substring matching and still find it. const uuid = registry.create('s1', 'bad-regex', 'echo "BUILD_(OK literal"', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'BUILD_(OK') const result = await registry.waitFor(uuid, 'BUILD_(OK', 2000) expect(result.kind).toBe('found') if (result.kind === 'found') { expect(result.match).toBe('BUILD_(OK') } } finally { registry.disposeAll() } }) it('waitFor regex matches text a plain substring could not express', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'regex-version', 'echo version v1.2.3 ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'v1.2.3') const result = await registry.waitFor(uuid, String.raw`v\d+\.\d+\.\d+`, 2000) expect(result.kind).toBe('found') if (result.kind === 'found') { expect(result.match).toBe('v1.2.3') } } finally { registry.disposeAll() } }) it('waitFor returns found after the needle appears (async output)', async () => { const registry = new AgentPtyRegistry(testShell()) try { // Spawn a shell WITH a command (so output is guaranteed), but start // the wait for a DIFFERENT needle that only appears after we send // a follow-up command. This exercises the subscribe/poll path: the // wait must notice output arriving AFTER it started, not just find // pre-existing content. const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24) // Wait for the initial command's output so we know the shell is live. await waitForTranscript(registry, uuid, 'shell-ready', 10_000) // Start the wait BEFORE sending the follow-up command. The needle is // NOT in the transcript yet. ConPTY under test concurrency can be slow, // so the timeout is generous. const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000) // Give the wait a moment to subscribe, then send the command. await new Promise(resolve => setTimeout(resolve, 200)) registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r') const result = await waitPromise expect(result.kind).toBe('found') if (result.kind === 'found') { expect(result.needle).toBe('UNIQUE_NEEDLE_42') } } finally { registry.disposeAll() } }, 40_000) it('waitFor returns timeout when the needle never appears', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, '', 1000) // Wait for a needle that will never appear; use a short timeout to // keep the test fast. const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500) expect(result.kind).toBe('timeout') if (result.kind === 'timeout') { expect(result.timeoutMs).toBe(500) expect(result.totalLines).toBeGreaterThan(0) } } finally { registry.disposeAll() } }) it('waitFor throws on an empty needle', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24) await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow() } finally { registry.disposeAll() } }) it('waitFor throws on an unknown uuid', async () => { const registry = new AgentPtyRegistry(testShell()) await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow() }) it('waitFor returns skipped when the user skips from the sidebar', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'skip-ready') // waitFor registers its record synchronously (before the first poll // await), so the skip can fire immediately after the call. const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000) expect(await registry.skipWait(uuid)).toBe(1) const result = await waitPromise expect(result.kind).toBe('skipped') if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ') // Idempotent: nothing left to skip once the wait resolved. expect(registry.skipWait(uuid)).toBe(0) } finally { registry.disposeAll() } }) it('snapshot exposes waiting while a wait is active and clears after it ends', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'snap-ready') const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000) // Registration happens synchronously before waitFor's first await. expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9') expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number') expect(registry.skipWait(uuid)).toBe(1) const result = await waitPromise expect(result.kind).toBe('skipped') expect(registry.list('s1')[0]?.waiting).toBeUndefined() } finally { registry.disposeAll() } }) it('concurrent waits on one uuid: snapshot shows the latest needle, skipWait skips all', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'concurrent', '', process.cwd(), 80, 24) // The bare shell never emits either needle → both waits miss the fast // paths and register their records (synchronously, before waitFor's // first poll await — the documented concurrent-wait contract). const first = registry.waitFor(uuid, 'NEVER_A_XYZ', 60_000) const second = registry.waitFor(uuid, 'NEVER_B_XYZ', 60_000) // The banner mirrors the LATEST wait (waits.at(-1)). expect(registry.list('s1')[0]?.waiting?.needle).toBe('NEVER_B_XYZ') expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number') // One skip transitions EVERY active wait on the terminal. expect(registry.skipWait(uuid)).toBe(2) expect(await first).toEqual({ kind: 'skipped', needle: 'NEVER_A_XYZ' }) expect(await second).toEqual({ kind: 'skipped', needle: 'NEVER_B_XYZ' }) // Both resolved → the wait state cleared from the snapshot. expect(registry.list('s1')[0]?.waiting).toBeUndefined() // Idempotent: nothing left to skip. expect(registry.skipWait(uuid)).toBe(0) } finally { registry.disposeAll() } }) it('fires change listeners when a wait starts and ends', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'notify-ready') let changes = 0 const unsubscribe = registry.subscribe(() => { changes += 1 }) const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000) const afterStart = changes expect(afterStart).toBeGreaterThanOrEqual(1) expect(await registry.skipWait(uuid)).toBe(1) expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' }) expect(changes).toBeGreaterThan(afterStart) unsubscribe() } finally { registry.disposeAll() } }) it('skipWait rejects an unknown uuid with not-found', () => { const registry = new AgentPtyRegistry(testShell()) try { expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/) // The HTTP layer maps `status` straight onto the response, so the 404 // contract rides this field — assert it, not just the message. let thrown: unknown try { registry.skipWait('missing-uuid') } catch (error) { thrown = error } expect(thrown).toBeInstanceOf(SidebarError) expect((thrown as SidebarError).status).toBe(404) expect((thrown as SidebarError).code).toBe('not-found') } finally { registry.disposeAll() } }) it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24) // SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty // stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z. // These must NOT throw (the old kill(SIGINT) path threw on Windows). expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow() expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow() // The control byte was written to the pty; the shell received it. // On POSIX the line discipline generates the signal; on Windows // ConPTY translates it. Either way the write path is exercised. } finally { registry.disposeAll() } }) it('delivers SIGKILL via the process-termination path', () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24) // SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not // throw even when the pty's kill() rejects named signals — the // fallback to the default kill ensures the signal takes effect. expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow() } finally { registry.disposeAll() } }) })