Merge origin/master into feat/remote-host-wake

Resolves CLAUDE.md count tables (route counts recounted on the merged
tree: 235 handlers, sessions 37) and keeps both the host-wake and the
reboot-restore banner in index.html.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
Randalix
2026-09-18 22:20:41 +02:00
co-authored by Claude Opus 5
77 changed files with 5422 additions and 368 deletions
+180
View File
@@ -0,0 +1,180 @@
/**
* @fileoverview Output arriving after a pane capture survives the buffer load.
*
* `batchTerminalWrite` queues live terminal events while a buffer load runs,
* and `_finishBufferLoad` discards that queue by default. That is right when
* the loaded buffer is the server's accumulated byte history, which is current
* up to the response. A tmux pane capture is current only up to CAPTURE time,
* so anything arriving between the capture and the end of the chunked write is
* queued and then dropped, with nothing scheduling a re-fetch.
*
* The queue now stamps each entry with its arrival time, and a capture load
* replays the tail that arrived after the response headers. These drive the
* real client in chromium: the event is injected from inside the response's
* own `json()` call, which is the one place guaranteed to land after the
* headers and before the chunked write.
*
* Port: 3256 (capture load window)
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3256;
const BASE_URL = `http://localhost:${PORT}`;
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 60_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
/**
* Select the session with the terminal fetch stubbed, injecting one live event
* from inside `json()`. Returns how many terminal rows carry the marker, so a
* flush that replays too much fails as loudly as one that replays nothing.
*/
async function runLoad(page: Page, sessionId: string, source: string): Promise<number> {
return page.evaluate(
async ({ sid, src, marker }) => {
const app = (
window as unknown as {
app: {
selectSession: (id: string, o?: object) => Promise<void>;
_onSessionTerminal: (e: { id: string; data: string }) => void;
terminal: {
buffer: {
active: {
length: number;
getLine: (i: number) => { translateToString: (t: boolean) => string } | undefined;
};
};
};
};
}
).app;
const realFetch = window.fetch.bind(window);
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(typeof input === 'string' ? input : ((input as Request).url ?? input));
if (!url.includes('/terminal')) return realFetch(input as RequestInfo, init);
return Promise.resolve({
ok: true,
status: 200,
// `selectSession` timestamps the headers the moment this promise
// resolves, then calls json(). Injecting here puts the event after
// that timestamp and inside the load window, which is exactly the
// gap a pane capture cannot cover.
json: async () => {
app._onSessionTerminal({ id: sid, data: `\r\n${marker}\r\n` });
return {
success: true,
data: {
terminalBuffer: '\x1b[1;1Hcaptured frame line one\r\n',
status: 'idle',
fullSize: 512,
retainedBytes: 512,
truncated: false,
truncationReason: null,
source: src,
captureCols: 80,
captureRows: 24,
},
};
},
}) as unknown as Promise<Response>;
}) as typeof window.fetch;
try {
await app.selectSession(sid);
await new Promise((r) => setTimeout(r, 1200));
const buf = app.terminal.buffer.active;
let hits = 0;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes(marker)) hits += 1;
}
return hits;
} finally {
window.fetch = realFetch;
}
},
{ sid: sessionId, src: source, marker: MARKER }
);
}
async function openSession(page: Page): Promise<string> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm loads from /vendor, so the terminal appears a beat after the app.
// Without it every buffer assertion below would throw rather than compare.
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
timeout: 30_000,
});
return page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-load-window-test' }),
});
const body = await res.json();
return body.data?.session?.id ?? body.data?.id ?? body.id;
});
}
describe('output emitted during a capture load', () => {
let context: BrowserContext;
let page: Page;
afterAll(async () => {
await context?.close();
});
it('reaches the terminal exactly once when the buffer came from a pane capture', async () => {
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
expect(sessionId).toBeTruthy();
// Exactly once. The cutoff exists so the flush cannot also replay events the
// payload already carried, which would double the output rather than heal it.
expect(await runLoad(page, sessionId, 'mux-visible')).toBe(1);
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
await context.close();
}, 60_000);
it('stays dropped when the buffer came from the accumulated byte history', async () => {
// The byte history already contains everything up to the response, so
// replaying the queue on top of it would duplicate the output — most
// visibly Ink's cursor-up redraws. The discard has to survive this fix.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
// Without this, a failed create passes the zero-hit assertion below
// vacuously — nothing was loaded, so nothing was replayed.
expect(sessionId).toBeTruthy();
expect(await runLoad(page, sessionId, 'history')).toBe(0);
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
await context.close();
}, 60_000);
});
@@ -0,0 +1,131 @@
/**
* `WebServer.discardPartiallyBuiltSession()` against the real server object.
*
* The reboot-restore route calls this when a rebuild registers a session and
* then fails to start its pane. It has to be the exact inverse of
* `registerSessionWithLayout()` plus `setupSessionListeners()`, and it must NOT
* be the user-initiated delete: banking the session's token totals, demoting a
* pinned record or deleting the workspace's files would all be wrong for a
* session that never ran.
*
* These tests drive the real method rather than the route, because the route
* tests run against a mock context whose `discardPartiallyBuiltSession` is a
* one-line stub — an earlier version of this function left four registrations
* behind and every route test still passed.
*
* The retry assertion is the important one. `setupSessionListeners()` returns
* early when `sessionListenerRefs` still holds the session id, so a discard that
* leaves that entry makes the next attempt wire nothing at all, and the user
* gets a tab that never shows output.
*/
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { safeRmHomeTree } from './mocks/test-helpers.js';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
/** Reach the private collections the discard is responsible for emptying. */
interface ServerInternals {
sessions: Map<string, Session>;
sessionListenerRefs: Map<string, unknown>;
runSummaryTrackers: Map<string, unknown>;
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}
const WORKSPACE = join(homedir(), '.codeman-test-discard');
const SESSION_ID = 'a1b2c3d4e5f60718';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;
function buildSession(): Session {
return new Session({
id: SESSION_ID,
workingDir: WORKSPACE,
mode: 'claude',
name: 'rebuilt session',
mux,
useMux: true,
});
}
beforeAll(() => {
mkdirSync(WORKSPACE, { recursive: true });
// Test mode: no port is opened and no CLI is launched. One server for the file,
// stopped at the end: the constructor registers handlers on the module-level
// image, subagent, team and workflow watchers, and only stop() removes them.
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});
afterEach(async () => {
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
});
afterAll(async () => {
await server.stop().catch(() => {});
safeRmHomeTree(WORKSPACE);
});
describe('discarding a session whose pane never started', () => {
it('takes the session back out of the server', async () => {
const session = buildSession();
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(internals.sessions.has(SESSION_ID)).toBe(true);
await internals.discardPartiallyBuiltSession(SESSION_ID);
expect(internals.sessions.has(SESSION_ID)).toBe(false);
});
it('releases the listener registration, so a retry can wire itself again', async () => {
const first = buildSession();
await internals.registerSessionWithLayout(first);
await internals.setupSessionListeners(first);
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
const firstRefs = internals.sessionListenerRefs.get(SESSION_ID);
await internals.discardPartiallyBuiltSession(SESSION_ID);
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(false);
// The retry reuses the id by design. `setupSessionListeners()` returns early
// while the refs are still there, so a session built now would run blind: no
// terminal output, no status updates, no exit broadcast. Asserting a DIFFERENT
// refs object is what distinguishes wiring the retry from finding the corpse
// of the first attempt still in place.
const retry = buildSession();
await internals.registerSessionWithLayout(retry);
await internals.setupSessionListeners(retry);
const retryRefs = internals.sessionListenerRefs.get(SESSION_ID);
expect(retryRefs).toBeDefined();
expect(retryRefs).not.toBe(firstRefs);
});
it('stops the run-summary tracker, whose interval would otherwise keep firing', async () => {
const session = buildSession();
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
const tracker = internals.runSummaryTrackers.get(SESSION_ID) as { stop: () => void };
expect(tracker).toBeDefined();
// Dropping the map entry is not enough: the tracker arms a setInterval in its
// constructor, and only stop() clears it, so a discard that merely forgot the
// entry would leave the timer running for the life of the process.
const stopped = vi.spyOn(tracker, 'stop');
await internals.discardPartiallyBuiltSession(SESSION_ID);
expect(stopped).toHaveBeenCalled();
expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(false);
});
it('does nothing at all for a session it never registered', async () => {
await expect(internals.discardPartiallyBuiltSession('never-existed')).resolves.toBeUndefined();
});
});
+62 -3
View File
@@ -48,13 +48,12 @@ describe('install.sh generated-catalogue block', () => {
);
});
it('declares every array the detection code indexes', () => {
it('declares every array install.sh actually reads', () => {
for (const name of [
'CLI_IDS',
'CLI_LABELS',
'CLI_ENABLED',
'CLI_KIND',
'CLI_NPM',
'CLI_LAUNCHER_ONLY',
'CLI_DOCS',
'CLI_CMD_LINUX',
'CLI_CMD_DARWIN',
@@ -69,6 +68,17 @@ describe('install.sh generated-catalogue block', () => {
}
});
it('declares no array install.sh never reads', () => {
// CLI_KIND and CLI_NPM were generated and read by nothing (the .mjs/docker-hosts.ts
// producers read the JSON's `kind`/`npmPackage` fields directly; only these two bash
// arrays were dead). A generated-but-unread array is a maintenance trap the generator
// itself cannot warn about — it has no reader to check against — so this pins the
// opposite of the test above: naming what must NOT come back rather than what must.
for (const name of ['CLI_KIND', 'CLI_NPM']) {
expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is declared but nothing reads it`).toBe(false);
}
});
it('keeps no hand-written per-CLI detection behind', () => {
// The nine `*_SEARCH_PATHS` arrays and eighteen `check_<cli>`/`get_<cli>_path` pairs are
// what this change removes. One left behind would be a second source of truth that the
@@ -93,6 +103,16 @@ describe('install.sh generated-catalogue block', () => {
[]
);
});
it('keeps no dead generic-lookup helpers behind', () => {
// _cli_index/check_cli/get_cli_path were the ungenericized precursor to the per-CLI
// helpers above: same shape, one level of indirection, called from nowhere once the
// catalogue-driven menu and hints stopped needing a lookup-by-id. Unlike the per-CLI
// pairs these are exact names, not derived from the catalogue.
for (const fn of ['_cli_index()', 'check_cli()', 'get_cli_path()']) {
expect(CODE.includes(fn), `${fn} should have been removed as dead code`).toBe(false);
}
});
});
describe('install.sh trust boundary', () => {
@@ -245,3 +265,42 @@ describe('install.sh AI CLI install menu', () => {
expect(run.status).toBe(1);
});
});
describe('install.sh detect_all_clis and a disabled entry', () => {
// No stock entry ships disabled today, so this is characterization rather than a regression
// pin on real data: it drives the real function in a real bash with entry 0 fabricated
// disabled, and points its binary at `bash` — guaranteed resolvable via `command -v` — to
// prove the entry is genuinely never PROBED (CLI_FOUND_PATH stays empty) rather than merely
// filtered out downstream by every consumer's own `CLI_ENABLED` check.
function driveDetect(disableEntry0: boolean) {
const driver = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
# Point entry 0's first declared binary at something that WILL resolve, so a probe that
# runs at all finds it.
CLI_ALL_BINS[\${CLI_BIN_OFF[0]}]="bash"
${disableEntry0 ? 'CLI_ENABLED[0]="0"' : ''}
CLI_DETECT_DONE=""
detect_all_clis
echo "path0=[\${CLI_FOUND_PATH[0]}]"
echo "found=$CLI_FOUND_COUNT"
`;
const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000 });
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}
it('probes an enabled entry (control case)', () => {
const run = driveDetect(false);
expect(run.stdout, run.stderr).not.toContain('path0=[]');
expect(run.stdout).toContain('found=1');
});
it('never probes a disabled entry', () => {
const run = driveDetect(true);
expect(run.stdout, run.stderr).toContain('path0=[]');
expect(run.stdout).toContain('found=0');
});
});
@@ -0,0 +1,28 @@
/**
* The mock route context must offer everything the real one does.
*
* Route tests pass their context as `ctx as never`, and `tsconfig.json` includes
* only `src/**`, so no type check ever compares the mock against the ports. A
* port that gained a method left this mock missing it twice; both times the
* route under test threw a TypeError inside its own catch, and the suite
* reported a plausible-looking failure for an unrelated reason.
*
* So the comparison is made at runtime, against `WebServer.createRouteContext()`
* rather than against the port types, which is what keeps it from drifting: the
* server's own context object is the thing route modules are really given.
*/
import { describe, expect, it } from 'vitest';
import { WebServer } from '../../src/web/server.js';
import { createMockRouteContext } from './mock-route-context.js';
describe('the mock route context', () => {
it('offers every member the real route context does', () => {
const server = new WebServer(0, false, true);
const real = (server as unknown as { createRouteContext(): Record<string, unknown> }).createRouteContext();
const mock = createMockRouteContext() as unknown as Record<string, unknown>;
const missing = Object.keys(real).filter((key) => !(key in mock));
expect(missing, `mock-route-context.ts is missing: ${missing.join(', ')}`).toEqual([]);
});
});
+5
View File
@@ -61,6 +61,10 @@ export function createMockRouteContext(options?: {
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
reapplyPersistedSessionState: vi.fn(async () => {}),
discardPartiallyBuiltSession: vi.fn(async (id: string) => {
sessions.delete(id);
}),
getSessionStateWithRespawn: vi.fn((s: MockSession) => s.toState()),
// -- EventPort --
@@ -149,6 +153,7 @@ export function createMockRouteContext(options?: {
clearRespawnConfig: vi.fn(),
updateRespawnConfig: vi.fn(),
setHistoryLimit: vi.fn(async () => {}),
startStatsCollection: vi.fn(),
},
runSummaryTrackers: new Map(),
activePlanOrchestrators: new Map(),
+3
View File
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
this.lastSubmitAt = Date.now();
}
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
trackUserInput(_data: string): void {}
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
+395
View File
@@ -0,0 +1,395 @@
/**
* @fileoverview The decision half of reboot restore, and proof that the existing
* recovery construction path can CREATE a resumed pane.
*
* Three things are under test. `src/reboot-restore.ts` decides whether the
* machine rebooted and which dead sessions may be offered back. The plan
* registry in `src/web/reboot-restore-registry.ts` holds that offer between the
* boot that builds it and the click that spends it. The third is the claim the
* whole feature rests on: a `Session` built the way `restoreMuxSessions()`
* already builds one, but given no `muxSession` and a `resumeSessionId`, creates
* a fresh pane that resumes the old conversation. If that holds, the restore
* needs no new session-creation service.
*
* `reconcileSessions()` reports every session ALIVE under vitest, so the
* server's own boot pass cannot be reached from here. The decision logic is
* therefore driven directly, and the construction claim is driven through a real
* `Session` against the in-memory tmux layer vitest substitutes.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionState } from '../src/types.js';
import {
looksLikeHostReboot,
newestPersistedActivity,
planRebootRestore,
rejectAlreadyLive,
resolveResumeConversationId,
type RebootRestoreEntry,
} from '../src/reboot-restore.js';
import { RebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
const HOUR = 60 * 60 * 1000;
const NOW = 1_760_000_000_000;
function persistedSession(overrides: Partial<SessionState> & { id: string }): SessionState {
return {
// A live agent's record carries its process id; `/exit` persists null instead.
pid: 99999,
status: 'idle',
workingDir: '/tmp/spike',
currentTaskId: null,
createdAt: NOW - 4 * HOUR,
lastActivityAt: NOW - 2 * HOUR,
mode: 'claude',
...overrides,
} as SessionState;
}
describe('reboot detection', () => {
const base = {
livePaneCount: 0,
deadSessionCount: 2,
// The host came up 10 minutes ago, well after the sessions were last active.
uptimeSeconds: 600,
newestPersistedActivityAt: NOW - 2 * HOUR,
now: NOW,
};
it('calls it a reboot when the socket is empty and the host booted after the last activity', () => {
expect(looksLikeHostReboot(base)).toBe(true);
});
it('refuses when some panes survived, which is an ordinary server restart', () => {
expect(looksLikeHostReboot({ ...base, livePaneCount: 3 })).toBe(false);
});
it('refuses on a long-uptime host, where someone wiped the tmux socket by hand', () => {
// Up for 30 days: the sessions were active long AFTER this boot, so the panes
// went away for some reason other than the machine restarting.
expect(looksLikeHostReboot({ ...base, uptimeSeconds: 30 * 24 * 60 * 60 })).toBe(false);
});
it('refuses when nothing died', () => {
expect(looksLikeHostReboot({ ...base, deadSessionCount: 0 })).toBe(false);
});
it('reads the newest activity stamp across the persisted records', () => {
const persisted = {
a: persistedSession({ id: 'a', lastActivityAt: NOW - 5 * HOUR }),
b: persistedSession({ id: 'b', lastActivityAt: NOW - 1 * HOUR }),
};
expect(newestPersistedActivity(persisted)).toBe(NOW - 1 * HOUR);
});
});
describe('which dead sessions may be rebuilt', () => {
it('rebuilds a session that was simply running when the power went out', () => {
const persisted = { live: persistedSession({ id: 'live', status: 'busy' }) };
const plan = planRebootRestore(['live'], persisted, () => true);
expect(plan.restore.map((s) => s.sessionId)).toEqual(['live']);
});
it('never revives a session the user killed while pinned (COD-142 demotes it to stopped)', () => {
const persisted = { killed: persistedSession({ id: 'killed', status: 'stopped', pinned: true }) };
const plan = planRebootRestore(['killed'], persisted, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'killed', reason: 'intentionally-ended' }]);
});
it('never revives a session whose record an unpinned kill already deleted', () => {
const plan = planRebootRestore(['gone'], {}, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
});
it('never revives a pane whose PTY-exit breaker had tripped', () => {
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
});
it('leaves remote sessions to the COD-108 reconnect watcher', () => {
const persisted = {
r: persistedSession({
id: 'r',
remote: { hostId: 'h', host: 'example.test', username: 'u', sessionName: 'n', owned: true },
} as Partial<SessionState> & { id: string }),
};
expect(planRebootRestore(['r'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
});
it('leaves docker sessions alone, since the container may not be up', () => {
const persisted = {
d: persistedSession({ id: 'd', docker: { containerId: 'abc', caseId: 'c' } } as Partial<SessionState> & {
id: string;
}),
};
expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
});
it('skips a CLI whose history the claude transcript reader does not understand', () => {
const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
});
});
describe('a session with no attach process in its record', () => {
it('is refused, because there was nothing running to bring back', () => {
// A session that never started, or whose pane died outright. NOT a session
// the user ended with `/exit`: that keeps its pid, because the pid is the
// tmux attach process and `remain-on-exit` keeps the pane alive.
const persisted = { exited: persistedSession({ id: 'exited', status: 'idle', pid: null }) };
const plan = planRebootRestore(['exited'], persisted, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'not-running' }]);
});
it('still restores the session beside it that was attached when the power went', () => {
const persisted = {
exited: persistedSession({ id: 'exited', pid: null }),
running: persistedSession({ id: 'running', pid: 4242 }),
};
const plan = planRebootRestore(['exited', 'running'], persisted, () => true);
expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['running']);
expect(plan.skipped.map((s) => s.reason)).toEqual(['not-running']);
});
it('refuses a record with no pid field at all', () => {
const persisted = { odd: persistedSession({ id: 'odd', pid: undefined as unknown as null }) };
expect(planRebootRestore(['odd'], persisted, () => true).skipped[0].reason).toBe('not-running');
});
});
describe('a workspace that is no longer on disk', () => {
it('is kept out of the offer, so a click cannot scaffold a deleted repo', () => {
const persisted = { gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }) };
const plan = planRebootRestore(['gone'], persisted, () => false);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'workspace-missing' }]);
});
it('is judged per session, not for the batch', () => {
const persisted = {
kept: persistedSession({ id: 'kept', workingDir: '/tmp/still-here' }),
gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }),
};
const plan = planRebootRestore(['kept', 'gone'], persisted, (dir) => dir === '/tmp/still-here');
expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['kept']);
expect(plan.skipped.map((s) => s.reason)).toEqual(['workspace-missing']);
});
});
describe('a conversation that came back on its own before the click', () => {
const entry: RebootRestoreEntry = {
sessionId: 'abc',
workingDir: '/tmp/spike',
mode: 'claude',
resumeConversationId: 'conv-1',
state: persistedSession({ id: 'abc' }),
};
it('is skipped when the user resumed it by hand from the Resume list', () => {
// Same conversation, different session id: the Resume list creates a NEW id.
const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-1']));
expect(result.restore).toEqual([]);
expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
});
it('is skipped when a session with that id is already on the board', () => {
const result = rejectAlreadyLive([entry], new Set(['abc']), new Set());
expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
});
it('is rebuilt when neither its id nor its conversation is live', () => {
const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-other']));
expect(result.restore.map((e) => e.sessionId)).toEqual(['abc']);
expect(result.skipped).toEqual([]);
});
});
describe('the plan the banner spends', () => {
const all = () => true;
const entryFor = (sessionId: string, owner?: string): RebootRestoreEntry => ({
sessionId,
owner,
workingDir: '/tmp/spike',
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: persistedSession({ id: sessionId, owner }),
});
it('hands an entry to the first caller and nothing to the second', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a'), entryFor('b')]);
expect(registry.take(all, undefined, undefined).map((e) => e.sessionId)).toEqual(['a', 'b']);
// The double-click: two panes on one conversation is what this prevents.
expect(registry.take(all, undefined, undefined)).toEqual([]);
});
it('spends only the ids a caller asked for', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a'), entryFor('b')]);
expect(registry.take(all, ['b'], undefined).map((e) => e.sessionId)).toEqual(['b']);
expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
});
it("shows a user their own sessions and leaves another owner's alone", () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('mine', 'alice'), entryFor('theirs', 'bob')]);
const asAlice = (owner: string | undefined) => owner === 'alice';
expect(registry.list(asAlice).map((e) => e.sessionId)).toEqual(['mine']);
expect(registry.take(asAlice, undefined, 'alice').map((e) => e.sessionId)).toEqual(['mine']);
// Bob's entry is still on offer for Bob.
expect(registry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
});
it('puts back an entry that no pane was created for', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a')]);
const taken = registry.take(all, undefined, undefined);
registry.releaseFlight(undefined, taken);
expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
});
it('runs one restore at a time', () => {
const registry = new RebootRestoreRegistry();
expect(registry.beginSpending()).toBe(true);
expect(registry.beginSpending()).toBe(false);
registry.endSpending();
expect(registry.beginSpending()).toBe(true);
});
it('drops what a dismiss cleared', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a'), entryFor('b')]);
expect(registry.clear(all)).toBe(2);
expect(registry.list(all)).toEqual([]);
});
it('forgets a plan nobody took for a day', () => {
const registry = new RebootRestoreRegistry();
registry.set([entryFor('a')]);
const dayLater = Date.now() + 25 * HOUR;
const realNow = Date.now;
Date.now = () => dayLater;
try {
expect(registry.list(all)).toEqual([]);
} finally {
Date.now = realNow;
}
});
});
describe('which conversation a rebuilt pane resumes', () => {
it('prefers the chain tail, the conversation the CLI reported last', () => {
const state = persistedSession({
id: 'sess-1',
resumeSessionId: 'launch-id',
claudeSessionChain: ['launch-id', 'after-clear'],
});
expect(resolveResumeConversationId(state)).toBe('after-clear');
});
it('falls back to the id the session originally resumed', () => {
const state = persistedSession({ id: 'sess-1', resumeSessionId: 'resumed-id' });
expect(resolveResumeConversationId(state)).toBe('resumed-id');
});
it('falls back to the session id, which is what Claude was launched with', () => {
expect(resolveResumeConversationId(persistedSession({ id: 'sess-1' }))).toBe('sess-1');
});
});
describe('the recovery construction path can create a resumed pane', () => {
const workingDir = join(homedir(), 'codeman-cases', 'reboot-restore-spike');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(workingDir, { recursive: true, force: true });
});
/** Built exactly as the reboot pass builds one: no `muxSession`, plus a resume id. */
function rebuildFromPersistedState(state: SessionState, mux: TmuxManager): Session {
mkdirSync(workingDir, { recursive: true });
const session = new Session({
id: state.id,
workingDir,
mode: state.mode,
name: state.name,
createdAt: state.createdAt,
mux,
useMux: true,
resumeSessionId: resolveResumeConversationId(state),
owner: state.owner,
lastActivityAt: state.lastActivityAt,
claudeSessionChain: state.claudeSessionChain,
});
sessions.push(session);
return session;
}
it('creates a NEW mux session rather than needing one to attach to', async () => {
const mux = new TmuxManager();
const state = persistedSession({ id: 'aaaaaaa1-1111-4111-8111-111111111111', name: 'w1-spike' });
const session = rebuildFromPersistedState(state, mux);
expect(mux.getSessions()).toHaveLength(0);
await session.startInteractive();
const created = mux.getSessions();
expect(created).toHaveLength(1);
expect(created[0].sessionId).toBe('aaaaaaa1-1111-4111-8111-111111111111');
expect(created[0].workingDir).toBe(workingDir);
});
it('comes back pointed at the conversation the pane was holding', async () => {
const mux = new TmuxManager();
const state = persistedSession({
id: 'aaaaaaa2-2222-4222-8222-222222222222',
resumeSessionId: 'launch-id',
claudeSessionChain: ['launch-id', 'after-clear'],
});
const session = rebuildFromPersistedState(state, mux);
await session.startInteractive();
// The chain tail wins: a `/clear` before the reboot moved the CLI off the launch id.
expect(session.claudeSessionId).toBe('after-clear');
});
it('comes back idle, with no prompt sent and no autonomous loop armed', async () => {
const mux = new TmuxManager();
const state = persistedSession({
id: 'aaaaaaa3-3333-4333-8333-333333333333',
ralphEnabled: true,
respawnEnabled: true,
});
const session = rebuildFromPersistedState(state, mux);
await session.startInteractive();
// No prompt was queued: nothing is waiting on a task. The status itself is not
// assertable here, because the test PTY echoes and the activity detector reads
// that echo as work; in production the pane settles once the CLI finishes booting.
expect(session.currentTaskId).toBeNull();
// The pass never touches the tracker, so a persisted Ralph loop stays cold.
expect(session.ralphTracker.enabled).toBe(false);
});
it('keeps the owner it was persisted with, there being no request to read one from', async () => {
const mux = new TmuxManager();
const state = persistedSession({ id: 'aaaaaaa4-4444-4444-8444-444444444444', owner: 'alice' });
const session = rebuildFromPersistedState(state, mux);
await session.startInteractive();
expect(session.owner).toBe('alice');
expect(mux.getSessions()[0].owner).toBe('alice');
});
});
@@ -0,0 +1,377 @@
/**
* Reboot-restore route: what happens when a rebuild gets part-way and then fails.
*
* The other route test file deliberately uses workspaces that do not exist, so it
* never reaches `new Session()`. This one mocks the `Session` module so the route
* runs its whole construction path — `addSession`, `setupSessionListeners`,
* `reapplyPersistedSessionState`, `startInteractive` — and then throws.
*
* The mock is the only way in. Driven against a real server, `startInteractive()`
* does not throw for either obvious cause: the CLI resolver finds its binary by
* absolute path rather than through PATH, and tmux falls back to another
* directory rather than failing when it cannot enter the workspace. A mux-layer
* failure is what is left, and it cannot be provoked from a test. Without the
* mock this path would go unexercised, which is how the original version of this
* route shipped a session leak the tests could not see.
*
* It also covers the session caps, because those too are only reachable once the
* route is actually willing to build something.
*/
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
/** Set per test: whether the mocked `startInteractive()` rejects. */
let startShouldThrow = false;
/** Ordering log, so a test can assert what ran before the pane spawned. */
const callOrder: string[] = [];
vi.mock('../../src/session.js', () => ({
Session: class {
id: string;
mode: string;
name?: string;
workingDir: string;
owner?: string;
claudeSessionId: string | null = null;
constructor(config: { id: string; mode?: string; name?: string; workingDir: string; owner?: string }) {
this.id = config.id;
this.mode = config.mode ?? 'claude';
this.name = config.name;
this.workingDir = config.workingDir;
this.owner = config.owner;
}
async startInteractive() {
callOrder.push('startInteractive');
if (startShouldThrow) throw new Error('spawn claude ENOENT');
}
/** The mock route context projects a session through this on broadcast. */
toState() {
return { id: this.id, mode: this.mode, name: this.name, workingDir: this.workingDir, owner: this.owner };
}
},
}));
const { registerRebootRestoreRoutes } = await import('../../src/web/routes/reboot-restore-routes.js');
const { rebootRestoreRegistry } = await import('../../src/web/reboot-restore-registry.js');
const { installRouteErrorHandler } = await import('../../src/web/route-error-handler.js');
const { httpStatusForErrorCode } = await import('../../src/types.js');
const { createMockRouteContext } = await import('../mocks/index.js');
type ApiErrorCode = import('../../src/types.js').ApiErrorCode;
type RebootRestoreEntry = import('../../src/reboot-restore.js').RebootRestoreEntry;
type SessionState = import('../../src/types.js').SessionState;
/** A real directory, so the route's workspace checks pass and it reaches the build. */
const WORKSPACE = process.cwd();
function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
return {
sessionId,
name: `session ${sessionId}`,
workingDir: WORKSPACE,
owner,
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: {
id: sessionId,
pid: null,
status: 'idle',
workingDir: WORKSPACE,
currentTaskId: null,
createdAt: 1_760_000_000_000,
mode: 'claude',
owner,
} as SessionState,
};
}
async function createHarness(ctx: ReturnType<typeof createMockRouteContext>): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerRebootRestoreRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return app;
}
beforeEach(() => {
startShouldThrow = false;
callOrder.length = 0;
});
afterEach(() => {
rebootRestoreRegistry.reset();
vi.clearAllMocks();
});
describe('a rebuild that fails after the session is registered', () => {
it('reports why it failed rather than blaming the workspace', async () => {
startShouldThrow = true;
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const app = await createHarness(ctx);
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.restored).toEqual([]);
// Not `workspace-missing`: the directory is there, the agent would not start.
expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'rebuild-failed' }]);
await app.close();
});
it('does not leave a registered session with no pane behind it', async () => {
startShouldThrow = true;
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
// The session reached ctx.sessions via addSession; the route has to take it
// back out, or the board shows a tab whose pane never existed.
expect(ctx.discardPartiallyBuiltSession).toHaveBeenCalledWith('a');
expect(ctx.sessions.has('a')).toBe(false);
// NOT the user-initiated delete: that would bank this session's historical
// tokens into the lifetime totals, demote a pinned record to `stopped`, and
// delete the workspace's .claude-images.
expect(ctx.cleanupSession).not.toHaveBeenCalled();
await app.close();
});
it('keeps the entry on offer, so the user can fix the PATH and click again', async () => {
startShouldThrow = true;
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
await app.close();
});
});
describe('a rebuild that succeeds', () => {
it('re-applies the persisted state before the record is written again', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const app = await createHarness(ctx);
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
// A session built from a record carries none of the pin, token totals or
// custom-model selection, so persisting it first would replace the fuller
// record with the reduced one.
expect(ctx.reapplyPersistedSessionState).toHaveBeenCalled();
const reapplyOrder = (ctx.reapplyPersistedSessionState as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
const persistOrder = (ctx.persistSessionState as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
expect(reapplyOrder).toBeLessThan(persistOrder);
await app.close();
});
it('shapes the pane before it spawns, and restores the history after', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
(ctx.reapplyPersistedSessionState as ReturnType<typeof vi.fn>).mockImplementation(
async (_s: unknown, _saved: unknown, phase: string) => {
callOrder.push(`reapply:${phase}`);
}
);
(ctx.setupSessionListeners as ReturnType<typeof vi.fn>).mockImplementation(async () => {
callOrder.push('setupSessionListeners');
});
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
// `setupSessionListeners()` READS the image-watcher flag that `before-spawn`
// restores, so the phase has to precede it or the session comes back
// reporting the watcher as on with nothing watching. The custom-model
// environment has to reach the process, and the token totals must not land
// on a session whose pane never started.
expect(callOrder).toEqual([
'reapply:before-spawn',
'setupSessionListeners',
'startInteractive',
'reapply:after-spawn',
]);
await app.close();
});
it('tells every other board about the rebuilt session', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(ctx.broadcast).toHaveBeenCalledWith('session:created', expect.anything());
await app.close();
});
it('spends the entry, so it is no longer on offer', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions).toEqual([]);
await app.close();
});
});
describe('the session caps', () => {
it('counts the sessions it is itself creating, not just the ones it started with', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
// One seat short of the documented maximum of 50, counting the session the
// mock context seeds. A check that ran once before the loop would restore
// BOTH entries; only a per-iteration check refuses the second.
for (let i = 0; i < 48; i += 1) {
ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
}
const app = await createHarness(ctx);
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
expect(res.skipped).toEqual([{ sessionId: 'b', reason: 'capacity-reached' }]);
// Refused rather than lost: closing a session and clicking again works.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['b']);
await app.close();
});
it('refuses every entry when the board is already at the cap', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
for (let i = 0; i < 50; i += 1) {
ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
}
const app = await createHarness(ctx);
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.restored).toEqual([]);
expect(res.skipped.map((s: { reason: string }) => s.reason)).toEqual(['capacity-reached', 'capacity-reached']);
await app.close();
});
});
describe('a failure before any entry is considered', () => {
it('returns the whole plan rather than spending it', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('settings unreadable'));
const app = await createHarness(ctx);
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(res.statusCode).toBeGreaterThanOrEqual(500);
// The plan cannot be rebuilt once boot has pruned the records, so a throw
// anywhere in the route has to hand the entries back.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
await app.close();
});
it('releases the single flight, so the next click is not refused', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('settings unreadable'));
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
rebootRestoreRegistry.endSpending(undefined);
await app.close();
});
});
describe('a dismiss that lands while a restore is running', () => {
it('wins when an admin is restoring the entries and their owner dismisses', async () => {
const theirs = offerEntry('theirs', 'bob');
rebootRestoreRegistry.set([theirs]);
// An admin may spend another user's entries, so the caller doing the restore
// and the owner of what is being restored are different people.
const taken = rebootRestoreRegistry.take(() => true, undefined, 'admin');
expect(taken.map((e) => e.sessionId)).toEqual(['theirs']);
// Bob dismisses his own banner. Nothing of his is in the plan any more, and
// the restore is running under a different name than his.
rebootRestoreRegistry.clear((owner) => owner === 'bob');
rebootRestoreRegistry.releaseFlight('admin', taken);
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
});
it('wins when an admin dismisses everything mid-restore', async () => {
rebootRestoreRegistry.set([offerEntry('theirs', 'bob')]);
const taken = rebootRestoreRegistry.take(() => true, undefined, 'admin');
rebootRestoreRegistry.clear(() => true);
rebootRestoreRegistry.releaseFlight('admin', taken);
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
});
it('wins, rather than being undone when the route hands its entries back', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
// The user clicks Dismiss while the restore is between its take and its
// return. Driven through the ROUTE, so removing the generation argument from
// the route would make this fail.
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockImplementation(async () => {
rebootRestoreRegistry.clear(() => true);
throw new Error('settings unreadable');
});
const app = await createHarness(ctx);
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions).toEqual([]);
await app.close();
});
it('reaches an in-flight restore the dismisser can see, even once its entries are taken', async () => {
const mine = offerEntry('mine', 'alice');
rebootRestoreRegistry.set([mine]);
const taken = rebootRestoreRegistry.take((owner) => owner === 'alice', undefined, 'alice');
expect(taken).toHaveLength(1);
// The plan is empty now, so the dismiss has nothing of Alice's left in the
// plan; it has to reach the entry the restore is holding.
rebootRestoreRegistry.clear((owner) => owner === 'alice');
rebootRestoreRegistry.releaseFlight('alice', taken);
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
});
it('does not reach another owner, whose unspent entries still come back', async () => {
const mine = offerEntry('mine', 'alice');
const theirs = offerEntry('theirs', 'bob');
rebootRestoreRegistry.set([mine, theirs]);
// Bob is mid-restore, holding his own entry.
const bobsTaken = rebootRestoreRegistry.take((owner) => owner === 'bob', undefined, 'bob');
expect(bobsTaken.map((e) => e.sessionId)).toEqual(['theirs']);
// Alice dismisses her own banner meanwhile.
rebootRestoreRegistry.clear((owner) => owner === 'alice');
// Bob's restore finishes and hands his entry back. Alice's dismiss covered
// her entries, not his, so his offer survives.
rebootRestoreRegistry.releaseFlight('bob', bobsTaken);
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* Reboot-restore route tests (src/web/routes/reboot-restore-routes.ts) via
* app.inject(), no live port.
*
* Every entry these tests put on offer names a workspace that does not exist, so
* the route's click-time workspace check rejects it before any `Session` is
* constructed. That keeps the tests on the route's own guards — taking, scoping,
* single-flighting and re-checking — and leaves pane creation to
* test/reboot-restore.test.ts, which drives a real `Session` for it.
*
* The routes read the process-wide `rebootRestoreRegistry` singleton, so every
* test resets it; a leaked entry would bleed into the next one.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { registerRebootRestoreRoutes } from '../../src/web/routes/reboot-restore-routes.js';
import { rebootRestoreRegistry } from '../../src/web/reboot-restore-registry.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
import { createMockRouteContext } from '../mocks/index.js';
import type { RebootRestoreEntry } from '../../src/reboot-restore.js';
import type { SessionState } from '../../src/types.js';
async function createHarness(authUser?: { username: string; role: 'admin' | 'user' }): Promise<FastifyInstance> {
return createHarnessWithCtx(createMockRouteContext(), authUser);
}
async function createHarnessWithCtx(
ctx: ReturnType<typeof createMockRouteContext>,
authUser?: { username: string; role: 'admin' | 'user' }
): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (authUser) {
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
registerRebootRestoreRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return app;
}
/** An entry whose workspace is deliberately absent, so no pane is ever created. */
function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
return {
sessionId,
name: `session ${sessionId}`,
workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
owner,
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: {
id: sessionId,
pid: null,
status: 'idle',
workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
currentTaskId: null,
createdAt: 1_760_000_000_000,
mode: 'claude',
owner,
} as SessionState,
};
}
afterEach(() => {
rebootRestoreRegistry.reset();
});
describe('GET /api/reboot-restore', () => {
it('reports nothing when no reboot left anything behind', async () => {
const app = await createHarness();
const res = await app.inject({ method: 'GET', url: '/api/reboot-restore' });
expect(res.statusCode).toBe(200);
expect(res.json().data.sessions).toEqual([]);
await app.close();
});
it('names what is on offer, and says the scrollback is not coming back', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const app = await createHarness();
const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(body.sessions.map((s: { id: string }) => s.id)).toEqual(['a', 'b']);
expect(body.scrollbackRestored).toBe(false);
await app.close();
});
it('never carries the persisted record itself to the browser', async () => {
rebootRestoreRegistry.set([offerEntry('a', 'alice')]);
const app = await createHarness({ username: 'alice', role: 'admin' });
const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(Object.keys(body.sessions[0]).sort()).toEqual(['id', 'mode', 'name', 'owner', 'workingDir']);
expect(body.sessions[0].state).toBeUndefined();
await app.close();
});
});
describe('POST /api/reboot-restore/restore', () => {
it('reports a workspace that is gone, and keeps offering it in case it comes back', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
const app = await createHarness();
const first = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(first.restored).toEqual([]);
expect(first.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-missing' }]);
// Nothing was built, so the entry goes back: a repo can be restored from a
// backup between two clicks, and losing the offer would be unrecoverable.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
await app.close();
});
it('never re-offers a conversation that is already open', async () => {
const entry = offerEntry('a');
rebootRestoreRegistry.set([entry]);
const app = await createHarness();
const ctx = createMockRouteContext({ sessionId: entry.sessionId });
// A session with that id is live, which is what the Resume list would produce.
const liveApp = await createHarnessWithCtx(ctx);
const res = (await liveApp.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'already-live' }]);
// Unlike a missing workspace, this one is dropped: it cannot stop being true.
const left = (await liveApp.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions).toEqual([]);
await liveApp.close();
await app.close();
});
it('spends only the sessions the click named', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const app = await createHarness();
const res = await app.inject({
method: 'POST',
url: '/api/reboot-restore/restore',
payload: { sessionIds: ['b'] },
});
expect(res.json().data.skipped).toEqual([{ sessionId: 'b', reason: 'workspace-missing' }]);
// 'a' was never taken, and 'b' came back because no pane was built for it.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
await app.close();
});
it('refuses a body it does not recognise rather than guessing', async () => {
const app = await createHarness();
const res = await app.inject({
method: 'POST',
url: '/api/reboot-restore/restore',
payload: { sessionIds: 'not-an-array' },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
await app.close();
});
it('turns a second concurrent restore away rather than interleaving it', async () => {
rebootRestoreRegistry.set([offerEntry('a')]);
// Claimed by a restore already in flight for this same owner (undefined in
// single-user mode, which is what the harness runs as).
expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
const app = await createHarness();
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(res.statusCode).toBe(409);
rebootRestoreRegistry.endSpending(undefined);
await app.close();
});
});
describe('POST /api/reboot-restore/restore: multi-user workspace confinement', () => {
const saved: Record<string, string | undefined> = {};
let realDir: string;
beforeEach(() => {
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
// This branch sits AFTER the existsSync check, so the workspace has to be
// real for the confinement rule to be the thing that rejects the entry.
realDir = mkdtempSync(join(tmpdir(), 'codeman-reboot-restore-real-'));
});
afterEach(() => {
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
rmSync(realDir, { recursive: true, force: true });
});
it("refuses a workspace outside the OWNER's case space, and leaves it on offer", async () => {
const entry = offerEntry('a', 'alice');
entry.workingDir = realDir;
(entry.state as { workingDir: string }).workingDir = realDir;
rebootRestoreRegistry.set([entry]);
// An admin does the clicking. The confinement is still resolved against
// alice, the entry's OWNER: `isWorkingDirAllowed` waves an admin through, so
// reading the caller here would hand an admin the power to rebuild another
// user's session anywhere on the box.
const app = await createHarness({ username: 'root-user', role: 'admin' });
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
expect(res.statusCode).toBe(200);
expect(res.json().data.restored).toEqual([]);
expect(res.json().data.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-forbidden' }]);
// A withdrawn grant can be given back, so unlike `already-live` this is not
// the permanent kind of refusal and the entry stays claimable.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
await app.close();
});
});
describe('POST /api/reboot-restore/dismiss', () => {
it('drops the offer and leaves the banner with nothing to show', async () => {
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
const app = await createHarness();
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/dismiss', payload: {} });
expect(res.json().data.dismissed).toBe(2);
const after = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(after.sessions).toEqual([]);
await app.close();
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
*
* A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
* auto-naming can never overwrite a name a person chose, on this server or
* on the one that restores the session after a restart.
*
* Uses app.inject() — no real HTTP ports needed.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { Session } from '../../src/session.js';
import { SseEvent } from '../../src/web/sse-events.js';
describe('PUT /api/sessions/:id/name', () => {
let harness: RouteTestHarness;
let session: Session;
const updateSessionName = vi.fn(() => true);
beforeAll(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
// A REAL session, since the ownership flag lives on the class, not the mock.
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
harness.ctx.sessions.set(session.id, session as never);
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
});
afterAll(async () => {
await harness.app.close();
});
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
expect(session.nameSource).toBe('placeholder');
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: 'my window' },
});
expect(res.statusCode).toBe(200);
// The harness registers the bare route; the {success,data} envelope is a server-level hook.
expect(res.json()).toMatchObject({ name: 'my window' });
expect(session.name).toBe('my window');
expect(session.nameSource).toBe('manual');
expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
expect(session.name).toBe('my window');
expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
SseEvent.SessionUpdated,
expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
);
// What the restore path will read back: the persisted state carries the flag.
expect(session.toState().nameSource).toBe('manual');
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* @fileoverview Auto-naming a session after its first prompt (#376).
*
* The tracker sits on the raw keystroke stream, so most of these pin the
* per-key rules that a review of the first cut found missing: a bare Esc ate
* the next prompt's first character, a wheel report mid-word dropped half the
* prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
*
* Port: N/A (no server needed)
*/
import { describe, it, expect, vi } from 'vitest';
import { Session } from '../src/session.js';
import {
SubmittedPromptTracker,
deriveAutoSessionName,
composeAutoSessionName,
isGeneratedSessionName,
} from '../src/session-auto-name.js';
describe('SubmittedPromptTracker', () => {
it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix the')).toEqual([]);
expect(tracker.feed(' login bugs\x7f')).toEqual([]);
expect(tracker.feed('\r')).toEqual(['fix the login bug']);
expect(tracker.feed('\r')).toEqual([]);
expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
});
it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
const tracker = new SubmittedPromptTracker();
tracker.feed('\x1b');
expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
tracker.feed('\x1b');
expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
// Esc then digits and punctuation used to grow the escape buffer without bound.
tracker.feed('\x1b');
expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
// A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
// is Alt+s by the terminal's own encoding and stays swallowed).
tracker.feed('\x1b');
tracker.feed('\x1b');
expect(tracker.feed('still here\r')).toEqual(['still here']);
});
it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
});
it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
});
it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
expect(tracker.feed('\x1bOB\r')).toEqual([]);
expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
expect(tracker.feed('\x10x\r')).toEqual([]);
expect(tracker.feed('\x12search\r')).toEqual([]);
expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
// Ctrl+C empties the composer, which also clears the taint.
expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
});
it('keeps bracketed-paste newlines inside the draft', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
'line one line two line three plus typed',
]);
// A paste split across chunks stays a paste.
tracker.feed('\x1b[200~first\r');
expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
});
it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
const tracker = new SubmittedPromptTracker();
tracker.feed('Fix the login bug');
tracker.feed('\n');
expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
});
it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
});
it('keeps the HEAD of an over-long draft', () => {
const tracker = new SubmittedPromptTracker();
const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
expect(prompt).toHaveLength(8192);
// Backspaces past the cap consume the overflow before the kept text.
const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
expect(again).toHaveLength(8190);
});
it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
// Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
});
it('resumes a CSI split across chunks', () => {
const tracker = new SubmittedPromptTracker();
tracker.feed('abc\x1b[');
expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
});
});
describe('deriveAutoSessionName', () => {
it('takes the first sentence, drops the full stop, and bounds the length', () => {
expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
expect(cut.endsWith('…')).toBe(true);
expect(cut).toMatch(/^(word )+word…$/);
});
it('does not cut on an abbreviation early in the prompt', () => {
expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
});
it('returns null for commands and empties, but not for paths', () => {
expect(deriveAutoSessionName('/clear')).toBeNull();
expect(deriveAutoSessionName('/model opus')).toBeNull();
expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
expect(deriveAutoSessionName('! npm test')).toBeNull();
expect(deriveAutoSessionName(' ')).toBeNull();
expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
});
it('strips control bytes and ANSI before the title is persisted', () => {
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
});
});
describe('composeAutoSessionName', () => {
it('keeps the placeholder as a prefix so the case and the counter survive', () => {
expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
});
it('honours the rename cap in UTF-16 units', () => {
const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
expect(name.length).toBeLessThanOrEqual(40);
expect(name.startsWith('w3-myapp: ')).toBe(true);
expect(name.endsWith('…')).toBe(true);
expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
});
it('recognises only the generated w/s + number + case form', () => {
expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
expect(isGeneratedSessionName('s1-shell')).toBe(true);
expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
expect(isGeneratedSessionName('alpha')).toBe(false);
});
});
describe('Session name ownership', () => {
it('infers placeholder vs manual from the name and persists the source', () => {
const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(placeholder.nameSource).toBe('placeholder');
expect(placeholder.toState().nameSource).toBe('placeholder');
expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
// The boot restore passes the persisted source, which outranks the inference.
const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
expect(recovered.nameSource).toBe('auto');
expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
});
it('names once: the first prompt takes it, later prompts and renames do not', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
expect(session.name).toBe('w1-demo: fix the login bug');
expect(session.nameSource).toBe('auto');
expect(session.applyAutoName('w1-demo: 1')).toBe(false);
expect(session.name).toBe('w1-demo: fix the login bug');
session.name = 'mine';
expect(session.nameSource).toBe('manual');
expect(session.applyAutoName('other')).toBe(false);
expect(session.name).toBe('mine');
});
it('consumes the first prompt even when the composed name is unchanged', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo')).toBe(false);
expect(session.nameSource).toBe('auto');
});
});
describe('Session promptSubmitted', () => {
function withFakePty(session: Session): ReturnType<typeof vi.fn> {
const write = vi.fn();
(session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
return write;
}
it('emits for user input only, after the bytes reached the PTY', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
// No PTY yet: the write fails and nothing is reported.
expect(session.write('lost\r', { fromUser: true })).toBe(false);
expect(prompts).toEqual([]);
const write = withFakePty(session);
expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
expect(prompts).toEqual([]);
expect(session.write('fix the ', { fromUser: true })).toBe(true);
expect(session.write('login bug\r', { fromUser: true })).toBe(true);
expect(prompts).toEqual(['fix the login bug']);
expect(write).toHaveBeenCalledTimes(3);
// The pane's last-Enter stamp is kept for EVERY write, user or not.
expect(session.lastSubmitAt).toBeGreaterThan(0);
});
it('never feeds the tracker for a shell session', () => {
const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
withFakePty(session);
expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
session.trackUserInput('cd src\r');
expect(prompts).toEqual([]);
});
it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
withFakePty(session);
session.write('Fix the login bug', { fromUser: true });
session.trackUserInput('\n');
session.write('Also add tests.\r', { fromUser: true });
expect(prompts).toEqual(['Fix the login bug Also add tests.']);
});
it('reports through writeViaMux only when the mux accepted the input', async () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
const sendInput = vi.fn(async () => false);
(session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
(session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
expect(prompts).toEqual([]);
sendInput.mockResolvedValue(true);
expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
expect(prompts).toEqual(['delivered']);
expect(await session.writeViaMux('/clear\r')).toBe(true);
expect(prompts).toEqual(['delivered']);
});
});
+67
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
import { SseEvent } from '../src/web/sse-events.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -20,4 +21,70 @@ describe('session listener wiring', () => {
);
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
/** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
function autoNameDeps(session: Session, enabled: boolean) {
const deps = {
updateSessionName: vi.fn(() => true),
persistSessionState: vi.fn(),
broadcast: vi.fn(),
getSessionStateWithRespawn: vi.fn(() => session.toState()),
isAutoNameEnabled: vi.fn(async () => enabled),
};
return {
deps,
refs: createSessionListeners(session, deps as unknown as Parameters<typeof createSessionListeners>[1]),
};
}
it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
const { deps, refs } = autoNameDeps(session, true);
// A slash command yields no title and leaves the session eligible; the
// setting is not even read for it.
refs.promptSubmitted('/clear');
await flush();
expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
expect(session.name).toBe('w1-demo');
refs.promptSubmitted('整理登录模块并补充测试');
await flush();
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
expect(session.nameSource).toBe('auto');
expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
expect(deps.persistSessionState).toHaveBeenCalledWith(session);
expect(deps.broadcast).toHaveBeenCalledWith(
SseEvent.SessionUpdated,
expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
);
// The second prompt never reaches the setting: the tab is named.
refs.promptSubmitted('1');
await flush();
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
});
it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
const { deps, refs } = autoNameDeps(session, false);
refs.promptSubmitted('fix the login bug');
await flush();
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('w1-demo');
// Still a placeholder: flipping the setting on names the NEXT prompt.
expect(session.nameSource).toBe('placeholder');
expect(deps.updateSessionName).not.toHaveBeenCalled();
session.name = '人工命名';
refs.promptSubmitted('新的任务不能覆盖人工命名');
await flush();
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('人工命名');
expect(deps.persistSessionState).not.toHaveBeenCalled();
});
});
+233 -21
View File
@@ -1,20 +1,31 @@
/**
* @fileoverview Regression tests for the buffer-load flush path (COD-144).
* @fileoverview Regression tests for the buffer-load flush path: what becomes of
* the live terminal events queued while a buffer load runs, once the load ends.
*
* Bug: newly launched Shell sessions rendered BLANK until a tab-switch. The
* buffer-load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`)
* QUEUES live SSE terminal events while `_isLoadingBuffer` is true, then on
* completion DISCARDS the queue (`_loadBufferQueue = null`). That de-dup is
* correct for an established session (the fetched buffer already contains the
* queued output, so replaying it would duplicate Ink redraws). But for a
* brand-new shell the fetch resolves BEFORE the PTY emits its prompt — the
* fetched buffer is empty and the prompt arrives only as a queued event, which
* then gets discarded → blank terminal.
* Two rules, each from a real bug.
*
* Fix: `_finishBufferLoad(owner, { flushQueued })` REPLAYS the queued events
* through `batchTerminalWrite()` (after `_isLoadingBuffer` is cleared, so they
* write through normally) ONLY when the load painted nothing. The default path
* (no opts) still discards, preserving de-dup for established sessions.
* COD-144: newly launched Shell sessions rendered BLANK until a tab-switch. The
* load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`) queues
* live events while `_isLoadingBuffer` is true and used to DISCARD the queue on
* completion. Right for a buffer built from the server's byte history (the
* queued output is already in it, so replaying it duplicates Ink redraws),
* wrong for a brand-new shell whose fetch resolves BEFORE the PTY emits its
* prompt: the prompt arrived only as a queued event and was thrown away. A
* caller that knows the load painted nothing passes `{ flushQueued: true }`
* and the queue is REPLAYED through `batchTerminalWrite()` after
* `_isLoadingBuffer` is cleared, so the events write through normally.
*
* #436: a tmux pane capture is current only as of the instant `capture-pane`
* ran, so everything the CLI printed between the capture and the end of the
* chunked write was queued and dropped, and its next partial redraw landed on
* a frame the terminal never received. Queue entries now carry their arrival
* time and `_finishBufferLoad` takes a `since` cutoff, so a capture load
* replays exactly the tail that arrived after the response headers. All four
* fetch-and-write paths take that policy from one helper,
* `_bufferLoadFinishOpts`, and a static scan below pins each of them to it,
* because the same fix had already been written into one path out of four,
* twice. A path that replays and then restores a scroll position re-takes the
* sticky-scroll baseline (`_syncStickyScrollBaseline`), pinned the same way.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts). We extract the REAL
@@ -56,10 +67,10 @@ type BufferLoadApp = {
_bufferLoadSeq: number;
_bufferLoadOwner: string | null;
_isLoadingBuffer: boolean;
_loadBufferQueue: string[] | null;
_loadBufferQueue: { at: number; data: string }[] | null;
batchTerminalWrite: (data: string) => void;
_beginBufferLoad: (owner?: string) => string;
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean; since?: number }) => boolean;
};
/**
@@ -84,10 +95,57 @@ function makeApp() {
return { app, writes };
}
/** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */
function pushWhileLoading(app: BufferLoadApp, data: string) {
// Mirrors batchTerminalWrite's queue branch: if loading, push to the queue.
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data);
/**
* A stub carrying the REAL `batchTerminalWrite` on top of the real begin/finish
* methods, so a replay samples the sticky-scroll baseline exactly as it does in
* the browser. The terminal is a fake whose `buffer.active` the test moves by
* hand, which is what a caller's `scrollToLine` does to a real one.
*/
function makeScrollApp() {
const buffer = { viewportY: 0, baseY: 100 };
const app = {
buffer,
terminal: { buffer: { active: buffer } },
sessions: new Map(),
activeSessionId: null,
pendingWrites: [] as string[],
writeFrameScheduled: false,
_wasAtBottomBeforeWrite: false,
_bufferLoadSeq: 0,
_bufferLoadOwner: null as string | null,
_isLoadingBuffer: false,
_loadBufferQueue: null as { at: number; data: string }[] | null,
_scheduleTerminalWriteFlush: vi.fn(),
batchTerminalWrite: mixin.batchTerminalWrite as (data: string) => void,
isTerminalAtBottom: mixin.isTerminalAtBottom as () => boolean,
_syncStickyScrollBaseline: mixin._syncStickyScrollBaseline as () => void,
_beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
_finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
};
return app;
}
/**
* Slice one class method out of app.js, from its header to the next method's.
*
* Bounding the slice matters: the two methods checked below are not followed by
* a JSDoc block, so a scan for the next comment would run on into unrelated
* code and match its scroll calls instead of theirs.
*/
function methodBody(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found in app.js`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
/**
* Simulate a live SSE event arriving while a buffer load is in progress.
* Mirrors batchTerminalWrite's queue branch, which stamps each entry with its
* arrival time so a flush can replay only the tail (see the `since` tests).
*/
function pushWhileLoading(app: BufferLoadApp, data: string, at = performance.now()) {
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push({ at, data });
}
describe('buffer-load flush (COD-144)', () => {
@@ -153,11 +211,165 @@ describe('buffer-load flush (COD-144)', () => {
// State untouched — still loading, queue intact, nothing replayed.
expect(app._isLoadingBuffer).toBe(true);
expect(app._bufferLoadOwner).toBe('real-owner');
expect(app._loadBufferQueue).toEqual(['queued']);
expect(app._loadBufferQueue).toEqual([{ at: expect.any(Number), data: 'queued' }]);
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
// ── The tmux-capture tail: `since` ──
//
// A pane capture is a point-in-time frame taken part-way through the fetch, so
// it holds what arrived BEFORE the capture and nothing after. selectSession
// passes the response's arrival time as `since`, which splits the queue at
// exactly that line: pre-capture events are already painted and must stay
// dropped, post-capture events exist nowhere else and must be replayed.
it('flushes only the entries at or after `since`', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-since');
pushWhileLoading(app, 'already-in-the-capture', 100);
pushWhileLoading(app, 'arrived-at-the-headers', 200);
pushWhileLoading(app, 'arrived-after-the-headers', 300);
app._finishBufferLoad(owner, { flushQueued: true, since: 200 });
// The pre-capture event stays dropped; the boundary entry counts as after.
expect(writes).toEqual(['arrived-at-the-headers', 'arrived-after-the-headers']);
});
it('flushQueued without `since` still replays the whole queue', () => {
// The COD-144 path: a brand-new session's first prompt predates the
// response, so cutting the queue would drop the only content it has.
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-no-since');
pushWhileLoading(app, 'prompt', 10);
pushWhileLoading(app, 'more', 20);
app._finishBufferLoad(owner, { flushQueued: true });
expect(writes).toEqual(['prompt', 'more']);
});
it('a `since` past every entry flushes nothing', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-since-late');
pushWhileLoading(app, 'old', 10);
app._finishBufferLoad(owner, { flushQueued: true, since: 999 });
expect(writes).toEqual([]);
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
});
// ── Re-entering one load ──
//
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
// opens it again under the SAME owner when it starts writing. A reset on that
// second call would silently throw away everything queued during the fetch,
// which on the capture path is output no buffer holds.
it('re-entering the same load keeps what the queue already holds', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-reenter');
pushWhileLoading(app, 'arrived-during-the-fetch', 100);
// chunkedTerminalWrite re-opens the load it was handed.
app._beginBufferLoad(owner);
pushWhileLoading(app, 'arrived-during-the-write', 200);
app._finishBufferLoad(owner, { flushQueued: true, since: 50 });
expect(writes).toEqual(['arrived-during-the-fetch', 'arrived-during-the-write']);
});
it('a genuinely different load still starts with an empty queue', () => {
const { app, writes } = makeApp();
app._beginBufferLoad('load-first');
pushWhileLoading(app, 'belongs-to-the-abandoned-load', 100);
// A tab switch starts a new load under a new owner. Its events are not ours.
const second = app._beginBufferLoad('load-second');
pushWhileLoading(app, 'belongs-to-this-load', 200);
app._finishBufferLoad(second, { flushQueued: true, since: 0 });
expect(writes).toEqual(['belongs-to-this-load']);
});
// ── The sticky-scroll baseline across a replay ──
//
// `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before queueing, and
// `flushPendingWrites` scrolls to the bottom off that sample. The replay runs
// inside `chunkedTerminalWrite` before its promise resolves, with the terminal
// freshly reset and rewritten, so the sample is always true. A caller that
// then restores the reader's position would have that restore undone.
it('the replay latches the baseline true, and the viewport restore re-takes it', () => {
const app = makeScrollApp();
const owner = app._beginBufferLoad('load-scroll');
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
// The load ends with the terminal reset and rewritten, so it reads as bottom.
app.buffer.viewportY = app.buffer.baseY;
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
expect(app._wasAtBottomBeforeWrite).toBe(true);
// The caller now puts the reader back where they were reading.
app.buffer.viewportY = 40;
app._syncStickyScrollBaseline();
// The next flush must leave them there.
expect(app._wasAtBottomBeforeWrite).toBe(false);
});
it('a restore that lands back at the bottom keeps sticky scroll armed', () => {
const app = makeScrollApp();
const owner = app._beginBufferLoad('load-scroll-bottom');
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
app.buffer.viewportY = app.buffer.baseY;
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
app._syncStickyScrollBaseline();
// A reader who was already at the bottom still wants to be carried along.
expect(app._wasAtBottomBeforeWrite).toBe(true);
});
it('both callers that restore a scroll position re-take the baseline', () => {
// The wiring lives in app.js, outside this file's vm harness. Without it the
// two methods below restore the viewport and the next flush undoes it.
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
for (const method of ['_onSessionNeedsRefresh', '_maybeRefetchFullHistory']) {
const body = methodBody(source, method);
const restoreAt = body.lastIndexOf('scrollToLine(');
const syncAt = body.indexOf('this._syncStickyScrollBaseline()');
expect(restoreAt, `${method} no longer restores a scroll position`).toBeGreaterThan(-1);
expect(syncAt, `${method} never re-takes the baseline`).toBeGreaterThan(-1);
expect(syncAt, `${method} re-takes the baseline before its restore`).toBeGreaterThan(restoreAt);
}
});
it('every path that fetches a terminal buffer and writes it asks the shared helper', () => {
// Drift guard. The first version of this fix covered one of the four paths,
// and a later pass found it still covering one of four. Nothing else in the
// gate stops a fifth path, or an inlined `{ flushQueued: true }`, from
// splitting the policy up again; the browser suite that would notice does
// not run in CI.
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
for (const method of [
'selectSession',
'_onSessionNeedsRefresh',
'_onSessionClearTerminal',
'_maybeRefetchFullHistory',
]) {
expect(methodBody(source, method), `${method} decides the flush policy itself`).toContain(
'this._bufferLoadFinishOpts('
);
}
});
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-empty');
+177 -8
View File
@@ -49,6 +49,39 @@ function loadTerminalUiHarness(mode: string) {
return { app, writes };
}
/**
* Swap in a terminal whose write() parses ASYNCHRONOUSLY, the way xterm.js does.
*
* The real renderer queues the chunk and applies it later, firing the write
* callback once it has been parsed; a redraw that addresses a row past the
* viewport (Codex's status line) drags the viewport to the live bottom at that
* point, not when write() returns. `parse()` runs that pending work.
*/
function attachAsyncParsingTerminal(app: any, opts: { viewportY: number; baseY: number }) {
const buffer = { viewportY: opts.viewportY, baseY: opts.baseY };
const pending: Array<() => void> = [];
app.terminal.buffer = { active: buffer };
app.terminal.write = vi.fn((_data: string, callback?: () => void) => {
pending.push(() => {
buffer.viewportY = buffer.baseY; // the redraw lands
callback?.();
});
});
app.terminal.scrollToLine = vi.fn((line: number) => {
buffer.viewportY = line;
});
app.terminal.scrollToBottom = vi.fn(() => {
buffer.viewportY = buffer.baseY;
});
return {
buffer,
parse: () => {
const queued = pending.splice(0, pending.length);
for (const run of queued) run();
},
};
}
function loadAppHarness() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const fetchMock = vi.fn();
@@ -322,6 +355,51 @@ describe('terminal flush budget', () => {
expect(app._bufferLoadOwner).toBe(null);
});
// ── Which payloads end their load by replaying the queue ──
//
// A pane capture is current only up to capture time, so the tail that arrived
// after the response exists nowhere else and has to be replayed. The server's
// accumulated byte history is current up to the response, so replaying on top
// of it would duplicate output. `_bufferLoadFinishOpts` is the one place that
// decides this, for all four paths that fetch a terminal buffer and write it.
it('replays the tail for a visible-pane capture', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({ source: 'mux-visible' }, 1234)).toEqual({
flushQueued: true,
since: 1234,
});
});
it('replays the tail for a full-history capture', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({ source: 'mux-full-history' }, 1234)).toEqual({
flushQueued: true,
since: 1234,
});
});
it('discards the queue for the accumulated byte history', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({ source: 'history' }, 1234).flushQueued).toBe(false);
});
it('discards the queue for a payload that names no source', () => {
// Fails toward the safe answer: a duplicated Ink redraw corrupts the screen,
// while a dropped tail is repaired by the CLI's next full repaint.
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({}, 1234).flushQueued).toBe(false);
expect(app._bufferLoadFinishOpts(undefined, 1234).flushQueued).toBe(false);
});
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
const { app } = loadTerminalUiHarness('codex');
const scrollToBottom = vi.fn();
@@ -337,20 +415,111 @@ describe('terminal flush budget', () => {
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
const { app } = loadTerminalUiHarness('codex');
const buffer = { viewportY: 40, baseY: 100 };
app.terminal.buffer = { active: buffer };
app.terminal.write = vi.fn(() => {
buffer.viewportY = buffer.baseY;
});
app.terminal.scrollToLine = vi.fn((line: number) => {
buffer.viewportY = line;
});
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
parse();
expect(buffer.viewportY).toBe(40);
});
// Issue #358. xterm.js parses on its own schedule, so the buffer still holds
// the pre-write viewport the instant write() returns: restoring there compared
// the anchor against itself, did nothing, and left the redraw free to drag the
// viewport to the live bottom a tick later. The previous regression passed
// because its write mock moved the viewport synchronously, which real xterm
// never does. These drive the callback explicitly instead.
it('restores the history anchor only AFTER xterm has parsed the write (#358)', () => {
const { app } = loadTerminalUiHarness('codex');
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
// Nothing has parsed yet, so nothing may have been restored yet either.
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
expect(buffer.viewportY).toBe(40);
parse();
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(40);
expect(buffer.viewportY).toBe(40);
});
it('holds the anchor across consecutive Codex redraws', () => {
const { app } = loadTerminalUiHarness('codex');
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
for (const frame of ['\x1b[55;1H\x1b[2m• Working (6s)', '\x1b[55;1H\x1b[2m• Working (7s)']) {
app.pendingWrites.push(frame);
app.flushPendingWrites();
parse();
expect(buffer.viewportY).toBe(40);
}
});
it('holds the anchor across a chunked write whose remainder is deferred', () => {
const { app } = loadTerminalUiHarness('codex');
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
// Over the 32KB codex frame budget, so the flush defers a remainder and the
// second chunk goes out from the write callback's reschedule.
app.pendingWrites.push('x'.repeat(40000));
app.flushPendingWrites();
parse();
expect(buffer.viewportY).toBe(40);
app.flushPendingWrites();
parse();
expect(buffer.viewportY).toBe(40);
expect(app.pendingWrites).toHaveLength(0);
});
it('drops the anchor when the user switched sessions before the write parsed', () => {
// The anchor indexes the buffer it came from. selectSession() resets the
// terminal and chunk-loads a different scrollback, so replaying row 40 into
// that one is a jump to an arbitrary place, not a restore. Only reachable now
// that the restore runs a parse later than the write.
const { app } = loadTerminalUiHarness('codex');
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
app.activeSessionId = 'session-2'; // the user clicked another tab
parse();
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
expect(buffer.viewportY).toBe(buffer.baseY);
});
it('drops the anchor while a buffer load is replaying history', () => {
const { app } = loadTerminalUiHarness('codex');
const { parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
app._isLoadingBuffer = true; // chunkedTerminalWrite owns the viewport now
parse();
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
});
it('does not bounce off the bottom when the sticky flag and an anchor disagree', () => {
// _wasAtBottomBeforeWrite is captured at the frame's first batchTerminalWrite
// and the anchor at flush time, so a scroll-up in between leaves both live.
// The anchor wins: scrolling to the bottom and back would be a visible jump.
const { app } = loadTerminalUiHarness('codex');
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
parse();
expect(app.terminal.scrollToBottom).not.toHaveBeenCalled();
expect(buffer.viewportY).toBe(40);
});
});
@@ -9,7 +9,10 @@
* (stopPropagation, not stopImmediatePropagation) does not silence it;
* - a `composed: true` insertText preceded by a keydown — the shape Chrome on
* Android delivers — is dropped by xterm and recovered by us, exactly once;
* - a keystroke xterm DOES handle is delivered exactly once, not twice.
* - a keystroke xterm DOES handle is delivered exactly once, not twice;
* - a character committed in the SAME page task as Enter reaches the send
* path ahead of the `\r`, which is the ordering the zero-delay timer
* alone cannot produce.
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally:
@@ -146,6 +149,84 @@ describe('orphaned terminal input recovery wiring', () => {
expect(second.sent.join('')).toBe('z');
});
/**
* The batched shape an Android soft keyboard actually delivers when the user
* taps the last character and then Enter: the character's keydown, its
* `composed: true` insertText, and Enter's keydown all land in ONE page task,
* before any zero-delay timer can run.
*
* This is the ordering half of the fix, and the half the unit harness cannot
* reach: the unit tests prove WHICH candidate is forwarded, this proves WHEN.
* Resolving the pending candidate only on its 0 ms timer loses the character
* outright here, because by the time that timer runs xterm has already
* emitted the `\r` and bumped the canonical counter past the candidate's
* snapshot, so it stands down. Draining at the next keydown, from xterm's
* custom key handler (which runs before xterm processes that key), puts the
* character on the wire ahead of the `\r`.
*/
async function batchedCommitThenEnter(data: string) {
return page.evaluate(async (text) => {
const app = (window as any).app;
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
const originalSessionId = app.activeSessionId;
const originalLocalEcho = app._localEchoEnabled;
const originalSendInput = app._sendInputAsync;
const originalPendingInput = app._pendingInput;
const originalLastKeystrokeTime = app._lastKeystrokeTime;
const sent: string[] = [];
try {
app.activeSessionId = 'cod388-browser-batched';
app._localEchoEnabled = false;
app._pendingInput = '';
app._lastKeystrokeTime = 0;
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
textarea.focus();
// One task, no awaits between the three dispatches.
const charDown = new KeyboardEvent('keydown', {
key: 'Unidentified',
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(charDown, { keyCode: { value: 65 }, which: { value: 65 } });
textarea.dispatchEvent(charDown);
textarea.value = text;
textarea.dispatchEvent(
new InputEvent('input', { data: text, inputType: 'insertText', bubbles: true, composed: true })
);
const enterDown = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(enterDown, { keyCode: { value: 13 }, which: { value: 13 } });
textarea.dispatchEvent(enterDown);
await new Promise((resolve) => setTimeout(resolve, 80));
return { wire: sent.join('') };
} finally {
app.activeSessionId = originalSessionId;
app._localEchoEnabled = originalLocalEcho;
app._sendInputAsync = originalSendInput;
app._pendingInput = originalPendingInput;
app._lastKeystrokeTime = originalLastKeystrokeTime;
textarea.value = '';
}
}, data);
}
it('delivers a character committed in the same task as Enter BEFORE the carriage return', async () => {
const { wire } = await batchedCommitThenEnter('o');
// Not '\r' (character lost, the defect) and not '\ro' (recovered too late).
expect(wire).toBe('o\r');
});
it('sends nothing for a keydown that produces no input event', async () => {
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
expect(sent).toEqual([]);
+46
View File
@@ -218,6 +218,52 @@ describe('orphaned terminal input recovery', () => {
expect(reads).toEqual([]);
});
it('delivers the last character BEFORE the Enter that submits it (defect 4)', () => {
// Android soft keyboards commit the last character and send the Enter key in
// ONE InputConnection transaction, so the `input` event and the Enter keydown
// are processed before any zero-delay timer runs. Two things then went wrong
// with a candidate that only resolved on its timer:
//
// 1. ORDER — xterm emits '\r' synchronously from the Enter keydown, and the
// local-echo composer submits `pendingText` right there. The recovered
// character arrived one macrotask too late to be part of the prompt.
// 2. LOSS — that '\r' bumps the canonical counter, so by the time the
// candidate resolved, `canonicalCount > snapshot` read as "xterm spoke
// for this keystroke" and stood the recovery down. The character was
// dropped outright: every message sent from the phone lost its last
// character.
//
// Resolving pending candidates synchronously at the NEXT keydown fixes both:
// the counter still holds the value it had when that candidate was created,
// and the byte reaches the composer ahead of the Enter.
const h = harness();
h.keydown();
h.input('o');
expect(h.emitted).toEqual([]);
h.keydown({ key: 'Enter' });
expect(h.emitted).toEqual(['o']);
// xterm now emits '\r' for the Enter. The already-resolved candidate must
// not fire a second time when its timer is flushed.
h.controller.notifyCanonicalData();
h.flushTimers();
expect(h.emitted).toEqual(['o']);
expect(h.pendingTimers()).toBe(0);
});
it('still stands down at the next keydown when xterm spoke for the candidate', () => {
// The synchronous resolve must not become a "forward everything" path: a
// keystroke xterm delivered itself is still a duplicate if recovered.
const h = harness();
h.keydown();
h.input('x');
h.controller.notifyCanonicalData();
h.keydown({ key: 'Enter' });
h.flushTimers();
expect(h.emitted).toEqual([]);
});
it('ignores input events that are not committed text', () => {
const h = harness();
for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) {