mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
On codex-cli 0.162.0, with 0.163.0 released, every launch first shows the
"Update available" prompt, and choosing Skip leads to a variant of the
workspace trust prompt: "2. Back to Agent Command Center" over the key-hint
row "enter continue · esc back". The pattern only accepted cancel,
interrupt, quit, answer or all as the closing word, so a fresh git repo
launched from Codeman sat on that trust prompt reading idle, with no red
alert and no blocked signal (measured live on a throwaway instance).
Adding "back" is safe because the hint row must still START with one of
the anchored hints ("enter continue" here): the popups the user opens at
the keyboard (/model, /permissions, /review, Implement this plan?) end in
"esc back" too, but their row starts with "enter select", so they stay
unmatched, as every existing negative fixture confirms. The new fixture is
the live capture with the scratch path replaced.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
422 lines
19 KiB
TypeScript
422 lines
19 KiB
TypeScript
/**
|
||
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
|
||
*
|
||
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
|
||
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
|
||
* to run the following command?" used to read as plainly idle, and one parked on a
|
||
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
|
||
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
|
||
* session reads it at every settle and on a timer while it is up, and the server raises
|
||
* exactly what a Claude `permission_prompt` hook raises, alert-only.
|
||
*
|
||
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
|
||
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
|
||
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
|
||
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
|
||
*/
|
||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||
import { readFileSync } from 'node:fs';
|
||
import { Session } from '../src/session.js';
|
||
import { getCli } from '../src/config/cli-registry/index.js';
|
||
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
|
||
import {
|
||
isBlockedScreen,
|
||
BLOCKED_TAIL_LINES,
|
||
BLOCKED_MAX_TAIL_LINES,
|
||
BLOCKED_RECHECK_MS,
|
||
BLOCKED_CLEAR_READS,
|
||
BLOCKED_QUIET_RECHECK_MS,
|
||
IDLE_SILENCE_MS,
|
||
PANE_PROBE_MIN_INTERVAL_MS,
|
||
} from '../src/session-activity.js';
|
||
import type { SessionMode } from '../src/types.js';
|
||
|
||
const DETECT = getCli('codex')!.capabilities.workDetect!;
|
||
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
|
||
const CODEX_WINDOW = DETECT.blockedLines!;
|
||
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
|
||
|
||
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
|
||
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
|
||
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
|
||
|
||
/** Every dialog codex was measured blocking on. */
|
||
const DIALOGS = [
|
||
'exec-approval.txt',
|
||
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
|
||
'exec-approval-w44.txt',
|
||
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
|
||
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
|
||
'edit-approval.txt',
|
||
'network-escalation-approval.txt',
|
||
'question.txt',
|
||
'question-option2-selected.txt',
|
||
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
|
||
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
|
||
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
|
||
'question-w60.txt',
|
||
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
|
||
'question-notes-typed.txt',
|
||
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
|
||
'trust-dialog-w40.txt',
|
||
// The same prompt, reached through the update prompt (`2. Skip`) on 0.162.0 with 0.163.0
|
||
// out: `2. Back to Agent Command Center` over `enter continue · esc back`. Captured
|
||
// 2026-10-16 from Codeman's own launch flags in a fresh git repo, at 120 columns.
|
||
'trust-dialog-back.txt',
|
||
];
|
||
|
||
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
|
||
const NOT_DIALOGS = [
|
||
'idle-start.txt',
|
||
'composer-typed.txt',
|
||
'working.txt',
|
||
'after-approve.txt',
|
||
'after-deny.txt',
|
||
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
|
||
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
|
||
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
|
||
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
|
||
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
|
||
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
|
||
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
|
||
'review-popup.txt',
|
||
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
|
||
];
|
||
|
||
describe('codex blockedLine on live captures', () => {
|
||
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
|
||
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
|
||
expect(CODEX_BLOCKED).not.toBeNull();
|
||
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
|
||
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
|
||
});
|
||
|
||
it.each(DIALOGS)('reads %s as blocked', (name) => {
|
||
expect(blocked(fixture(name))).toBe(true);
|
||
});
|
||
|
||
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
|
||
expect(blocked(fixture(name))).toBe(false);
|
||
});
|
||
|
||
it('needs the whole window for the narrowest question, and no more than it declares', () => {
|
||
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
|
||
// that stops short of it reads nothing, which is the pre-field behaviour, never a
|
||
// false alert.
|
||
const narrow = fixture('question-w36.txt');
|
||
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
|
||
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
|
||
});
|
||
|
||
it('sees the question dialogs that the working line used to claim', () => {
|
||
// Why a blocked screen has to WIN over the working line in the probe: the question's
|
||
// own key hints end in `esc to interrupt`.
|
||
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
|
||
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
|
||
expect(blocked(fixture(name)), name).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('codex blockedLine against a screen the agent writes', () => {
|
||
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
|
||
// path vary), hint row.
|
||
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
|
||
`› ${typed}`,
|
||
'',
|
||
footer,
|
||
' ? for shortcuts',
|
||
];
|
||
const forgedDialog = [
|
||
'• Here is what the dialog looks like:',
|
||
'› 1. Yes, proceed (y)',
|
||
' 2. No, and tell Codex what to do differently (esc)',
|
||
'',
|
||
' Press enter to confirm or esc to cancel',
|
||
];
|
||
|
||
it('refuses a forged dialog in the transcript, whatever follows it', () => {
|
||
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
|
||
// While a prompt is typed the hint row goes away, so the forged hint sits only two
|
||
// rows above the end: the composer row (column 0, `›`) still breaks the run.
|
||
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
|
||
// Even with a lowercase model and a short path, the footer row would have to end in
|
||
// one of the hint's own closing words.
|
||
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
|
||
});
|
||
|
||
it('refuses the working view with the dialog quoted right above the status line', () => {
|
||
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
|
||
expect(blocked(screen)).toBe(false);
|
||
});
|
||
|
||
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
|
||
expect(
|
||
blocked(
|
||
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
|
||
)
|
||
).toBe(false);
|
||
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
|
||
});
|
||
|
||
it('stays linear on a hostile window', () => {
|
||
// Many option rows and long rows, ending in something that is almost the hint: the
|
||
// pattern must give up quickly, since it runs on a timer while a dialog is up.
|
||
const row = `› 1. ${'x'.repeat(300)}`;
|
||
const screen = [
|
||
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
|
||
` Press enter to confirm ${'or '.repeat(60)}`,
|
||
].join('\n');
|
||
const started = performance.now();
|
||
for (let i = 0; i < 50; i++) blocked(screen);
|
||
expect(performance.now() - started).toBeLessThan(250);
|
||
expect(blocked(screen)).toBe(false);
|
||
});
|
||
|
||
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
|
||
const dialog = fixture('exec-approval.txt');
|
||
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
|
||
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
|
||
});
|
||
|
||
it('answers false for an empty or unreadable capture', () => {
|
||
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
|
||
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── The session's blocked state ────────────────────────────────────────────
|
||
|
||
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
|
||
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
|
||
|
||
type SessionInternals = {
|
||
_handleTerminalOutput(data: string): void;
|
||
_detectInteractiveActivity(data: string): void;
|
||
};
|
||
|
||
function feed(session: Session, data: string): void {
|
||
const internals = session as unknown as SessionInternals;
|
||
internals._handleTerminalOutput(data);
|
||
internals._detectInteractiveActivity(data);
|
||
}
|
||
|
||
/**
|
||
* What the pane shows, and how often the session has read it: every read is a synchronous
|
||
* `tmux capture-pane` on the live server.
|
||
*/
|
||
type FakeScreen = { text: string | null; captures?: number };
|
||
|
||
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
|
||
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
|
||
const mux = {
|
||
isAvailable: () => true,
|
||
capturePaneText: () => {
|
||
screen.captures = (screen.captures ?? 0) + 1;
|
||
return screen.text;
|
||
},
|
||
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
|
||
return new Session({
|
||
workingDir: '/tmp',
|
||
mode,
|
||
mux,
|
||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||
} as ConstructorParameters<typeof Session>[0]);
|
||
}
|
||
|
||
/**
|
||
* The pane changes the only way a real one can: tmux redraws it to the attached client,
|
||
* which reaches the session as output. A cursor move and an erase, with no composer glyph
|
||
* in it, so it marks activity without arming the idle confirmation.
|
||
*/
|
||
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
|
||
screen.text = text;
|
||
feed(session, '\x1b[2;1H\x1b[K');
|
||
}
|
||
|
||
/** Output, then quiet: the moment the idle confirmation reads the screen. */
|
||
function settle(session: Session): void {
|
||
for (let i = 0; i < 3; i++) {
|
||
feed(session, CODEX_REPAINT);
|
||
vi.advanceTimersByTime(1000);
|
||
}
|
||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||
}
|
||
|
||
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
|
||
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
|
||
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
|
||
return changes;
|
||
}
|
||
|
||
describe('Session.screenBlocked', () => {
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('raises once when the pane settles on an approval, and reads the session idle', () => {
|
||
vi.useFakeTimers();
|
||
const screen = { text: fixture('exec-approval.txt') };
|
||
const session = withFakePane(screen);
|
||
const changes = recordChanges(session);
|
||
|
||
settle(session);
|
||
expect(session.screenBlocked).toBe(true);
|
||
expect(session.status).toBe('idle');
|
||
expect(changes).toHaveLength(1);
|
||
expect(changes[0].blocked).toBe(true);
|
||
// The frame it was read from rides along, as the approval card's context.
|
||
expect(changes[0].frame).toContain('Would you like to run the following command?');
|
||
|
||
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
|
||
settle(session);
|
||
expect(changes).toHaveLength(1);
|
||
});
|
||
|
||
it('calls a question dialog blocked and idle, not working', () => {
|
||
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
|
||
// field the probe called this pane busy for as long as the question stayed up.
|
||
vi.useFakeTimers();
|
||
const session = withFakePane({ text: fixture('question.txt') });
|
||
settle(session);
|
||
expect(session.screenBlocked).toBe(true);
|
||
expect(session.status).toBe('idle');
|
||
});
|
||
|
||
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
|
||
vi.useFakeTimers();
|
||
const screen = { text: fixture('exec-approval.txt') as string | null };
|
||
const session = withFakePane(screen);
|
||
const changes = recordChanges(session);
|
||
settle(session);
|
||
expect(session.screenBlocked).toBe(true);
|
||
|
||
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
|
||
// what notices, and one read alone is not enough.
|
||
redraw(session, screen, fixture('working.txt'));
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
|
||
expect(session.screenBlocked).toBe(true);
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
|
||
expect(session.screenBlocked).toBe(false);
|
||
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
|
||
|
||
// Nothing keeps reading a screen with no dialog on it.
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
|
||
expect(changes).toHaveLength(2);
|
||
});
|
||
|
||
it('does not flap on one read that misses a dialog still on screen', () => {
|
||
// A capture inside a full-frame repaint can come back without the footer. Clearing on
|
||
// it would raise again a moment later: a second alert, push and webhook for one dialog.
|
||
vi.useFakeTimers();
|
||
const dialog = fixture('exec-approval.txt');
|
||
const screen = { text: dialog as string | null };
|
||
const session = withFakePane(screen);
|
||
const changes = recordChanges(session);
|
||
settle(session);
|
||
|
||
redraw(session, screen, fixture('working.txt'));
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
|
||
redraw(session, screen, dialog);
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
|
||
expect(session.screenBlocked).toBe(true);
|
||
expect(changes).toHaveLength(1);
|
||
});
|
||
|
||
it('keeps its state through an unreadable capture, in either direction', () => {
|
||
vi.useFakeTimers();
|
||
const screen = { text: fixture('exec-approval.txt') as string | null };
|
||
const session = withFakePane(screen);
|
||
settle(session);
|
||
// The pane moved, so the session looks, and tmux answers nothing it can read.
|
||
redraw(session, screen, null);
|
||
const before = screen.captures!;
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
|
||
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
|
||
expect(session.screenBlocked).toBe(true);
|
||
|
||
// ...and it is still watching: once the screen reads again without the dialog, it clears.
|
||
redraw(session, screen, fixture('after-approve.txt'));
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
|
||
expect(session.screenBlocked).toBe(false);
|
||
});
|
||
|
||
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
|
||
// Each read is a synchronous `tmux capture-pane` (about 8 ms of blocked event loop,
|
||
// measured 2026-10-14), and a dialog can sit unanswered overnight. It leaves the
|
||
// screen only through a repaint, which reaches the session as output, so a pane that
|
||
// drew nothing since the last reading that saw the dialog is still showing it.
|
||
vi.useFakeTimers();
|
||
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
|
||
const session = withFakePane(screen);
|
||
settle(session);
|
||
expect(session.screenBlocked).toBe(true);
|
||
|
||
const quietMs = 10 * 60_000;
|
||
const before = screen.captures!;
|
||
vi.advanceTimersByTime(quietMs);
|
||
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
|
||
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
|
||
expect(session.screenBlocked).toBe(true);
|
||
|
||
// The answer repaints the pane, and the clear comes as fast as before.
|
||
redraw(session, screen, fixture('after-approve.txt'));
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
|
||
expect(session.screenBlocked).toBe(false);
|
||
});
|
||
|
||
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
|
||
// tmux updates the pane before it sends this client the repaint, so a capture can see
|
||
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
|
||
// is right only while the last reading SAW the dialog: once one reading missed it,
|
||
// waiting for output could leave the alert up for the whole safety-net interval.
|
||
vi.useFakeTimers();
|
||
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
|
||
const session = withFakePane(screen);
|
||
settle(session);
|
||
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
|
||
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
|
||
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
|
||
expect(session.screenBlocked).toBe(true);
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
|
||
expect(session.screenBlocked).toBe(false);
|
||
});
|
||
|
||
it('raises again for the next dialog after a clear', () => {
|
||
vi.useFakeTimers();
|
||
const screen = { text: fixture('exec-approval.txt') as string | null };
|
||
const session = withFakePane(screen);
|
||
const changes = recordChanges(session);
|
||
settle(session);
|
||
redraw(session, screen, fixture('after-approve.txt'));
|
||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
|
||
screen.text = fixture('edit-approval.txt');
|
||
settle(session);
|
||
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
|
||
});
|
||
|
||
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
|
||
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
|
||
vi.useFakeTimers();
|
||
for (const mode of ['claude', 'gemini'] as const) {
|
||
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
|
||
const changes = recordChanges(session);
|
||
settle(session);
|
||
expect(session.screenBlocked, mode).toBe(false);
|
||
expect(changes, mode).toHaveLength(0);
|
||
}
|
||
});
|
||
|
||
it('drops the state when a new pane starts, announcing it', () => {
|
||
vi.useFakeTimers();
|
||
const session = withFakePane({ text: fixture('trust-dialog.txt') });
|
||
const changes = recordChanges(session);
|
||
settle(session);
|
||
expect(session.screenBlocked).toBe(true);
|
||
(session as unknown as { _resetBuffers(): void })._resetBuffers();
|
||
expect(session.screenBlocked).toBe(false);
|
||
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
|
||
});
|
||
});
|