mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-21 09:10:11 +02:00
feat(terminal-agent): sessionId-aware grant + scoped restart + eager spawn
Wires the pty-session-lease primitive (3aada48b) into terminal-agent so
the Commit 2 work in server.ts (next commit) can route /pty-restart and
re-attach by session identity rather than by single-use token.
Changes:
* validTokens: Set<string> → Map<string, string|null>. Each grant carries
its bound sessionId (or null for legacy single-grant callers). On WS
upgrade, the agent surfaces the bound sessionId via ws.data so open()
can register the session in the new reverse index.
* sessionsById: Map<sessionId, PtySession> — populated in open(),
cleared in close(). Required so /internal/restart can find and dispose
one specific session by id rather than enumerating all live sessions.
* /internal/restart: scoped to one sessionId. Codex T2 of the eng review
caught the gap — pre-spec the route would have disposed every PTY on
the agent, breaking pair-agent and any future multi-sidebar setup.
The body now requires `{sessionId}`; missing or unknown id returns
`{killed: 0}` and leaves siblings alone.
* maybeSpawnPty(ws, session): hoisted from the inline binary-frame spawn
block so both the legacy "spawn on first keystroke" trigger AND the
new `{type:"start"}` text-frame trigger land in the same code path.
Idempotent on session.spawned.
* `{type:"start"}` text frame: explicit spawn trigger. forceRestart
(extension side, lands in Commit 2C) sends this immediately on every
fresh WS so claude boots without requiring a keystroke. Pre-v1.44 the
lazy-binary-spawn pattern made the restart feel stuck.
* close(ws): drops the sessionsById entry alongside the existing
sessions WeakMap + validTokens cleanup. Commit 3 will revisit this to
keep the session alive for a 60s detach window before disposing.
Test (browse/test/terminal-agent-session-routing.test.ts):
* 8 static-grep tripwires pinning the load-bearing properties: validTokens
is a Map (not Set), sessionsById exists, /internal/restart is scoped
(negative-assert against enumerate-all patterns), WS upgrade plumbs
sessionId, maybeSpawnPty is the single spawn entry, close() drops the
index. Live spawn cycles belong in the e2e tier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 2 — terminal-agent sessionId routing + eager spawn.
|
||||
//
|
||||
// Live spawn tests would require a real claude binary on PATH and a Bun.serve
|
||||
// listener; both are e2e-tier. These static-grep tripwires defend the load-
|
||||
// bearing protocol changes:
|
||||
// - validTokens carries the sessionId binding (Map, not Set)
|
||||
// - sessionsById index exists for /internal/restart + (Commit 3) re-attach
|
||||
// - /internal/restart is scoped to one sessionId (codex T2 fix)
|
||||
// - {type:"start"} triggers spawn for eager UX after forceRestart
|
||||
// - maybeSpawnPty helper is the single entry point for both spawn paths
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
|
||||
test('1. validTokens is a Map binding token → sessionId', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Pre-Commit 2 was `Set<string>`; the Map carries the sessionId
|
||||
// binding that /internal/restart and (Commit 3) re-attach depend on.
|
||||
expect(src).toMatch(/const validTokens = new Map<string, string \| null>\(\)/);
|
||||
expect(src).not.toMatch(/const validTokens = new Set</);
|
||||
});
|
||||
|
||||
test('2. sessionsById reverse index exists', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/const sessionsById = new Map<string, PtySession>\(\)/);
|
||||
// Populated in open() — required so /internal/restart can find the session.
|
||||
expect(src).toMatch(/if \(sessionId\) sessionsById\.set\(sessionId, session\)/);
|
||||
});
|
||||
|
||||
test('3. /internal/grant binds an optional sessionId to the token', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
||||
expect(block).toContain('validTokens.set(body.token, sid)');
|
||||
expect(block).toContain('body?.sessionId');
|
||||
});
|
||||
|
||||
test('4. /internal/restart is scoped to one sessionId, not dispose-all', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
||||
expect(block).toContain('sessionsById.get(sid)');
|
||||
expect(block).toContain('disposeSession(session)');
|
||||
expect(block).toContain('sessionsById.delete(sid)');
|
||||
// Negative: must NOT enumerate all live sessions and dispose them
|
||||
// (codex T2 caught this — pre-spec the route killed every PTY on the
|
||||
// agent, breaking multi-sidebar / pair-agent setups).
|
||||
expect(block).not.toMatch(/for\s*\(\s*const\s+\[?ws/);
|
||||
});
|
||||
|
||||
test('5. WS upgrade surfaces sessionId on ws.data', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('validTokens.get(token) ?? null');
|
||||
expect(src).toMatch(/data:\s*\{\s*cookie:\s*token,\s*sessionId\s*\}/);
|
||||
});
|
||||
|
||||
test('6. eager spawn via {type:"start"} text frame', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/msg\?\.type === 'start'/);
|
||||
// Both spawn paths route through the same helper for parity.
|
||||
expect(src).toContain('function maybeSpawnPty(');
|
||||
expect(src).toMatch(/maybeSpawnPty\(ws, session\)/);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(block).toContain('sessionsById.delete(session.sessionId)');
|
||||
});
|
||||
|
||||
test('8. PtySession interface carries the sessionId field', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Whole interface — close paren is sufficient.
|
||||
const i = src.indexOf('interface PtySession {');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const j = src.indexOf('\n}', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('sessionId: string | null');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user