mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-02 14:35:40 +02:00
feat(terminal-agent): scrollback ring buffer + detach state machine + re-attach
The agent side of Commit 3 — the "magic" feature. A network blip (wifi
hiccup, MV3 panel suspend, brief Chromium pause) now silently reconnects
the sidebar to the SAME claude session with scrollback intact. No more
"Session ended" message + manual Restart click + losing your tool-call
output. Server-side /pty-session/reattach (25ef24e9) and the extension
re-attach loop (next commit) close the loop end-to-end.
Ring buffer (T10):
* Per-session frames: Buffer[] capped at 1 MB (env-overridable via
GSTACK_PTY_RING_BUFFER_BYTES). Each PTY write is one frame, so
eviction is at frame boundaries and never cuts a UTF-8 sequence or
ANSI CSI in half.
* appendToRingBuffer eviction loop keeps at least one frame even at
extreme caps — a single oversized frame can't empty the buffer.
* Alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l
sequences. lastIndexOf comparison so trailing state wins when both
appear in one render frame (quick tool-call open+close).
Replay payload (T5 — codex outside-voice):
* buildReplayPayload prefixes DECSTR soft reset (\x1b[!p) and
conditionally re-enters alt-screen if claude was in a tool call at
detach. The client writes RIS (\x1bc) FIRST to clear pre-blip xterm
content; the server's prelude resets character attributes; the ring
buffer replays cleanly on top.
* Order is enforced by the {type:"reattach-begin"} text frame the
agent sends right before the binary replay — client waits for it,
writes RIS, then treats the next binary frame as the replay payload.
Detach state machine (T9):
* PtySession.liveWs decouples the PTY callback from the original ws
closure. On re-attach, swapping session.liveWs is enough — the
on-data callback writes to the new ws automatically.
* close(ws, code, _reason): codes 4001 (intentional restart), 4404
(no-claude), and 1000 (clean exit) trigger immediate dispose.
Anything else (1006 abnormal, 1001 going-away from network blip /
panel suspend) starts a 60s detach timer instead. claude keeps
running, output keeps accumulating in the ring buffer.
* Detach timer is unref'd so the bun process can still exit cleanly
on natural shutdown.
* Sessions without a sessionId (legacy single-shot grants) can't
re-attach by definition — those fall through to immediate dispose.
Re-attach lookup (T9):
* WS open() checks sessionsById[sessionId] FIRST. If a detached
session is sitting there, cancel its detach timer, swap liveWs,
rebind the WS-keyed map, restart keepalive, send reattach-begin
+ replay payload. The PTY process is unchanged.
* /internal/restart now cancels any pending detach timer before
disposal — otherwise the timer would later try to dispose an
already-disposed session.
Env knobs for e2e:
* GSTACK_PTY_RING_BUFFER_BYTES — compress to 256 for eviction tests.
* GSTACK_PTY_DETACH_WINDOW_MS — compress to 1000 for "did the timer
fire?" tests without waiting a minute per assertion.
Tests:
* browse/test/terminal-agent-detach-reattach.test.ts — 10 static-grep
tripwires for the load-bearing properties: interface shape, env
knobs, eviction floor, alt-screen tracking, replay prelude
composition, re-attach lookup, close-code routing, detach timer
unref, /internal/restart timer cancellation, on-data through
session.liveWs.
* browse/test/terminal-agent-session-routing.test.ts test 7 widened
to match the new close(ws, code, _reason) signature.
* browse/test/terminal-agent-keepalive.test.ts test 3 widened
similarly. Both stay regressions for the prior contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
|
||||
//
|
||||
// The state machine is what turns a single network blip from "fall through
|
||||
// to ENDED state, click Restart" into "silent re-attach with scrollback
|
||||
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
|
||||
// in the e2e tier; these static-grep tripwires defend the load-bearing
|
||||
// protocol + correctness properties.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
|
||||
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const i = src.indexOf('interface PtySession {');
|
||||
const j = src.indexOf('\n}', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('liveWs: any | null');
|
||||
expect(block).toContain('ringBuffer: Buffer[]');
|
||||
expect(block).toContain('ringBufferBytes: number');
|
||||
expect(block).toContain('altScreenActive: boolean');
|
||||
expect(block).toContain('detached: boolean');
|
||||
expect(block).toContain('detachTimer:');
|
||||
});
|
||||
|
||||
test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
|
||||
expect(src).toContain('1024 * 1024');
|
||||
});
|
||||
|
||||
test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
|
||||
expect(src).toContain("'60000'");
|
||||
});
|
||||
|
||||
test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/function appendToRingBuffer\(/);
|
||||
// Eviction loop: must keep at least one frame even at extreme caps
|
||||
// (otherwise a single oversized frame would empty the buffer).
|
||||
expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
|
||||
expect(src).toContain('session.ringBuffer.length > 1');
|
||||
expect(src).toContain('session.ringBuffer.shift()');
|
||||
});
|
||||
|
||||
test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Canonical xterm enter/exit alt-screen sequences. Must update
|
||||
// session.altScreenActive so the replay prelude knows.
|
||||
expect(src).toContain('\\x1b[?1049h');
|
||||
expect(src).toContain('\\x1b[?1049l');
|
||||
expect(src).toContain('session.altScreenActive');
|
||||
});
|
||||
|
||||
test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/function buildReplayPayload\(/);
|
||||
// DECSTR soft reset — re-defaults character attributes after the
|
||||
// client's RIS clears the xterm buffer.
|
||||
expect(src).toContain('\\x1b[!p');
|
||||
// Conditionally re-enter alt-screen if claude was in a tool-call
|
||||
// (alt-screen mode) at detach.
|
||||
expect(src).toContain('session.altScreenActive');
|
||||
});
|
||||
|
||||
test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
|
||||
expect(block).toContain('sessionsById.get(sessionId)');
|
||||
expect(block).toContain('existing.liveWs = ws');
|
||||
expect(block).toContain('clearTimeout(existing.detachTimer)');
|
||||
// Tells the client to write RIS before treating the next binary
|
||||
// frame as replay.
|
||||
expect(block).toContain("type: 'reattach-begin'");
|
||||
expect(block).toContain('sendBinary(buildReplayPayload(existing))');
|
||||
});
|
||||
|
||||
test('8. WS close starts detach timer for non-intentional close codes', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const i = src.indexOf('close(ws');
|
||||
const j = src.indexOf('function handleTabState', i);
|
||||
const block = src.slice(i, j);
|
||||
// 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
|
||||
// exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
|
||||
// 60s detach grace.
|
||||
expect(block).toContain('code === 4001');
|
||||
expect(block).toContain('code === 4404');
|
||||
expect(block).toContain('code === 1000');
|
||||
expect(block).toContain('session.detached = true');
|
||||
expect(block).toContain('session.detachTimer = setTimeout');
|
||||
expect(block).toContain('DETACH_WINDOW_MS');
|
||||
// Detach timer must unref so the bun process can exit cleanly.
|
||||
expect(block).toContain('detachTimer as any)?.unref?.()');
|
||||
});
|
||||
|
||||
test('9. /internal/restart cancels detach timer before disposal', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
||||
// Without the cancellation, a later detach-timer fire would dispose a
|
||||
// session that's already been disposed by the explicit restart path.
|
||||
expect(block).toContain('clearTimeout(session.detachTimer)');
|
||||
});
|
||||
|
||||
test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Critical for re-attach correctness: the PTY's on-data callback
|
||||
// closes over `session`, not the original `ws`, so after re-attach
|
||||
// it routes to the new liveWs automatically.
|
||||
expect(src).toContain('session.liveWs.sendBinary');
|
||||
// Always append to the ring buffer regardless of attach state — so
|
||||
// a detached session still captures output for the next re-attach.
|
||||
expect(src).toContain('appendToRingBuffer(session, flush)');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -37,9 +37,11 @@ describe('terminal-agent WS keepalive (v1.44+)', () => {
|
||||
test('3. WS close handler clears the ping interval', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
||||
// close(ws) MUST clearInterval the pingInterval — otherwise we leak
|
||||
// timers across reconnects and the ping handler captures a dead ws ref.
|
||||
expect(wsBlock).toMatch(/close\s*\(\s*ws\s*\)/);
|
||||
// close(ws, code?, reason?) MUST clearInterval the pingInterval —
|
||||
// otherwise we leak timers across reconnects and the ping handler
|
||||
// captures a dead ws ref. Signature widened in Commit 3 to include
|
||||
// the close code for the detach state machine, hence the loose match.
|
||||
expect(wsBlock).toMatch(/close\s*\(\s*ws/);
|
||||
expect(wsBlock).toContain('clearInterval(session.pingInterval)');
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,13 @@ describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
|
||||
|
||||
test('7. close() drops sessionsById entry alongside ws cleanup', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, 'close(ws) {', 'function handleTabState');
|
||||
// Commit 3 widened the close signature to `close(ws, code, _reason)`
|
||||
// for the detach state machine. Match either shape so test is stable
|
||||
// across the rest of the long-lived-sidebar PR.
|
||||
const i = src.indexOf('close(ws');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const j = src.indexOf('function handleTabState', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('sessionsById.delete(session.sessionId)');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user