fix(idle): hold idle while the workspace trust dialog is on screen

The dialog's own prompt glyph armed idle confirmation and the quiet dialog passed it, so a fresh case reported idle about 4 s in. An agent that waited for idle and then prompted typed into the dialog, and its Enter confirmed No, exit and killed the pane.
This commit is contained in:
JD committed 2026-10-14 22:16:49 -04:00
1 parent a8a8bb71a3
commit 2a5f5b30be
7 files changed
+154 -16

No files matched your search

+112
View File
@@ -16,6 +16,17 @@
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
// The restart case below starts a real direct-PTY session; the real `claude` must never
// run from a test, so pty.spawn runs a harmless stand-in instead.
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (_file: string, _args: string[] | string, options: import('node-pty').IPtyForkOptions) =>
real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options),
};
});
import {
isTrustDialogScreen,
compactScreenText,
@@ -249,3 +260,104 @@ describe('Session trust-dialog auto-accept', () => {
expect(writes).toEqual([]);
});
});
describe('Session idle detection while the trust dialog is up', () => {
afterEach(() => vi.useRealTimers());
/** A just-launched pane (busy, inside the startup window) rendering `screen` (null: capture failed). */
function launchingSession(screen: () => string | null) {
const mux = { isAvailable: () => true, capturePaneText: () => screen() };
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as unknown as ConstructorParameters<typeof Session>[0]);
const internals = session as unknown as {
_detectInteractiveActivity(data: string): void;
_markActivity(): void;
_interactiveStartedAt: number;
_status: string;
};
internals._interactiveStartedAt = Date.now();
internals._status = 'busy'; // what startInteractive() leaves a new pane at
let idles = 0;
session.on('idle', () => idles++);
/** One repaint carrying a `❯`, which is what arms the idle confirmation. */
const paint = (chunk: string) => {
internals._markActivity();
internals._detectInteractiveActivity(chunk);
};
return { session, paint, idles: () => idles };
}
it('does not call the dialog idle, although its own ❯ arms the check', () => {
vi.useFakeTimers();
const { session, paint, idles } = launchingSession(() => RENDERED_DIALOG_2_1_252);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('busy');
expect(idles()).toBe(0);
});
it('goes idle once the dialog has been answered and the composer is up', () => {
vi.useFakeTimers();
let screen = RENDERED_DIALOG_2_1_252;
const { session, paint, idles } = launchingSession(() => screen);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(10_000);
expect(session.status).toBe('busy');
// No new output needed: the check keeps re-reading the screen on its own.
screen = RENDERED_MAIN_UI.replace('✻ Actualizing… (13m 23s · ↓ 47.5k tokens)', '');
vi.advanceTimersByTime(10_000);
expect(session.status).toBe('idle');
expect(idles()).toBe(1);
});
it('keeps holding when a capture fails after the dialog was seen', () => {
vi.useFakeTimers();
// A failed capture says nothing about whether the dialog went away.
let screen: string | null = RENDERED_DIALOG_2_1_252;
const { session, paint, idles } = launchingSession(() => screen);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(5000);
screen = null;
vi.advanceTimersByTime(20_000);
expect(session.status).toBe('busy');
expect(idles()).toBe(0);
});
it("does not carry the last pane's dialog verdict into a restarted pane", async () => {
// The session saw the dialog on its last pane, then restarted where no capture can
// replace that verdict (here the direct-PTY fallback, which has no pane to read; a
// failed capture keeps it the same way). The new pane must still be able to go idle.
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: false });
(session as unknown as { _lastPaneProbeTrustDialog: boolean })._lastPaneProbeTrustDialog = true;
try {
await session.startInteractive();
vi.useFakeTimers();
const internals = session as unknown as {
_detectInteractiveActivity(data: string): void;
_markActivity(): void;
};
internals._markActivity();
internals._detectInteractiveActivity('❯ ');
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('idle');
} finally {
vi.useRealTimers();
await session.stop();
}
});
it('lets a dialog-looking screen go idle outside the startup window', () => {
vi.useFakeTimers();
// A live agent can print the dialog's words hours in, and must not be held busy.
const { session, paint } = launchingSession(() => RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(10 * 60_000);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('idle');
});
});