fix(sessions): route antigravity launches through the ownership helpers

runAntigravity() landed on master after this branch was cut, so it kept the
exact pattern the rest of this PR removes: terminal.clear() plus direct
writeln into whatever session happened to be active. Merging master in
surfaced it, leaving one of six run modes still wiping the active session's
xterm on launch.

Also adds regression coverage that can actually see the bug. The existing
test drives the three helpers directly, so it stays green even when a run*()
function is reverted to writing at the terminal itself: reverting
runClaude()'s call site keeps all 16 tests passing. The new static guard
scans session-ui.js and fails if any run*() body touches
this.terminal.clear/writeln, which catches a regressed call site and would
have caught runAntigravity on its own. A second unit test covers the
home-screen path that nothing exercised: with no active session, launch
progress must still clear and render in the terminal.

Verified in a browser against a live instance. With a session active,
runShell() and runAntigravity() leave its terminal untouched (clear() calls:
0, writes: 0) and emit one info toast; on master the same run wipes the
session's marker text. The session-less home screen still clears and writes
exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-04 23:47:17 +02:00
parent 03bb40c78a
commit 292ba2c775
2 changed files with 71 additions and 6 deletions
+6 -6
View File
@@ -1120,9 +1120,7 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
this.terminal.focus();
try {
@@ -1130,8 +1128,10 @@ Object.assign(CodemanApp.prototype, {
const statusRes = await fetch('/api/antigravity/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
return;
}
}
@@ -1160,7 +1160,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
+65
View File
@@ -114,6 +114,71 @@ describe('Run launch synchronization', () => {
expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error');
});
it('still renders launch progress in the terminal on the session-less home screen', () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.activeSessionId = null; // home screen: nothing else owns the terminal
app.terminal = { clear: vi.fn(), writeln: vi.fn() };
app.showToast = vi.fn();
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
expect(ownsTerminal).toBe(true);
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session');
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session');
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed');
expect(app.showToast).not.toHaveBeenCalled();
});
/**
* Static guard over session-ui.js itself. The helpers above can be perfectly
* correct while a run*() entry point still writes to the shared xterm
* directly, which is the actual bug: a launch started while another session
* is active wipes that session's terminal, and _cleanupPreviousSession()
* then serializes the wiped view into its restore snapshot. Asserting on the
* helpers alone cannot see that, so pin the call sites here. This also
* covers run modes added later, which is how runAntigravity was caught.
*/
it('routes every run mode through the ownership helpers, never the terminal directly', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Methods live in one Object.assign(prototype, {...}) block at a fixed
// 2-space indent, so `\n },` reliably closes the one we are inside.
const bodies = new Map<string, string>();
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
for (let m = header.exec(src); m; m = header.exec(src)) {
const start = m.index + m[0].length;
const end = src.indexOf('\n },', start);
expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start);
bodies.set(m[1], src.slice(start, end));
}
// Fail loudly if the scan matched nothing: a silently empty scan would make
// every assertion below vacuously true.
expect([...bodies.keys()]).toEqual(
expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity'])
);
for (const [name, body] of bodies) {
expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear(');
expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain(
'this.terminal.writeln('
);
}
});
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
const runBtn = {
disabled: false,