mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-01 22:15:43 +02:00
449144cda5
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>
91 lines
4.2 KiB
TypeScript
91 lines
4.2 KiB
TypeScript
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);
|
|
}
|