Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog.d/codex-held-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Fixed
- A chat message to Codex is no longer typed into one of Codex's own lists or fields (/model,
/permissions, /review, /theme, /resume, /statusline, /rename, the agents and transcript views),
which herdr reports at rest: the paste was dropped and its Enter took the row under the cursor,
which could save a model as the default for every new session. It is refused before anything is
typed and stays in the message box. A queued message refused so is tried again as soon as the pane
redraws, instead of waiting up to a minute after the list closes.
- Codex at Ultra reasoning effort, which draws its composer as `»`: the still-open notice arms, a
fresh pane opens as an empty chat with its model and effort, and its first answer is matched to
the conversation instead of leaving the chat unavailable.
22 changes: 22 additions & 0 deletions server/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,28 @@ describe("Codex submitted first exchange", () => {
expect(matchCodexFirstExchange(screen, [row(), row("old", started - 60_000, "Other fixture", "Different answer")], started)).toBe("new");
});

it("matches at Ultra too, whose input box is drawn with \u00bb: the box and the rows under it are no part of the answer", () => {
const ultra = `OpenAI Codex (v1.0)\n\n\u203a ${prompt}\n\n\u2022 ${answer}\n\n\u00bb Ask Codex to do anything\n\n GPT-6-Astra ultra \u00b7 /fixture\n \u2190 for agents \u00b7 ? for shortcuts\n`;
expect(matchCodexFirstExchange(ultra, [row()], started)).toBe("new");
expect(matchCodexFirstExchange(ultra.replace(answer, "Another answer."), [row()], started)).toBeNull();
// a box still empty under a prompt: no answer yet
expect(matchCodexFirstExchange(`OpenAI Codex (v1.0)\n\n\u203a ${prompt}\n\n\u00bb Ask Codex to do anything\n`, [row()], started)).toBeNull();
});

it("reads a \u00bb row inside an answer as the answer's, not the box: only the last prompt row can be the box", () => {
const longer = `OpenAI Codex (v1.0)\n\n\u203a ${prompt}\n\n\u2022 ${answer}\n\u00bb And more of another answer.\n\n\u203a Ask Codex to do anything\n`;
expect(matchCodexFirstExchange(longer, [row()], started)).toBeNull();
});

it("takes no draft in the \u00bb box for an answer: what was typed may quote another conversation's", () => {
const said = "An answer long and varied enough to count, 0123456789 with every single word its own.";
const candidate = { path: "other", text: jsonl(message("user", prompt), message("assistant", said, "final_answer")) };
for (const mark of ["\u203a", "\u00bb"]) {
expect(matchCodexTranscript(`OpenAI Codex (v1.0)\n\n${mark} ${said}\n\n GPT-6-Astra ultra \u00b7 /fixture\n`, [candidate])).toBeNull();
}
expect(matchCodexTranscript(`OpenAI Codex (v1.0)\n\n\u2022 ${said}\n\n\u00bb Ask Codex to do anything\n`, [candidate])).toBe("other");
});

it("matches a short greeting only when the complete exchange is unique", () => {
const greeting = screen.replace(prompt, "hello");
const own = row("new", started, "hello");
Expand Down
18 changes: 14 additions & 4 deletions server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,17 +995,26 @@ const markedDisplay = (text: string): string => text.normalize("NFKC").replace(/

/**
* A Codex screen cut at what was typed and what tools did: each prompt (a line Codex starts with
* "›", a prompt sent or the input box, or an indented "↳", a message queued to be sent), each tool
* "›", a prompt sent or the input box, "»" the input box at Ultra reasoning effort (Codex 0.164), or
* an indented "↳", a message queued to be sent), each tool
* call Codex shows (a "•" line it starts with what it did or does: "Ran", "You ran", "Called",
* "Explored", "Searched", "Waited", "Running" and so on) and each tool's output (an indented "└"), with
* the indented and blank lines each wraps onto, and the stretches between them. Neither a prompt nor a
* tool's call or output is evidence of the conversation shown: any may quote any conversation's answer.
*/
/** The input box's row: the last row Codex starts with `›` or `»`. Only there is `»` the box (at Ultra), never in an answer above it. */
function codexBoxAt(lines: readonly string[]): number {
for (let index = lines.length - 1; index >= 0; index--) if (/^[\u203a\u00bb](?:[ \t]|$)/.test(lines[index]!)) return index;
return -1;
}

function codexScreenParts(screen: string): { prompt: boolean; tool?: true; text: string }[] {
const parts: { prompt: boolean; tool?: true; text: string }[] = [];
for (const line of screen.split("\n")) {
const lines = screen.split("\n");
const box = codexBoxAt(lines);
for (const [index, line] of lines.entries()) {
const last = parts.at(-1);
const prompt = /^(?:\u203a|\s*\u21b3(?:\s|$))/.test(line);
const prompt = /^(?:\u203a|\s*\u21b3(?:\s|$))/.test(line) || (index === box && line.startsWith("\u00bb"));
// "You ran": a shell command the user ran from the composer, as Codex 0.163 titles it. An answer that
// starts with one of these words is taken for a call too: telling them apart could show another's chat
const tool = !prompt && (/^\s+\u2514(?:\s|$)/.test(line)
Expand Down Expand Up @@ -1681,7 +1690,8 @@ function displayedFirstExchange(screen: string, keepCompletionFooter = false): C
}
if (answerAt < 0) return null;
const user = lines.slice(promptAt, answerAt).map((line, index) => index === 0 ? line.slice(1) : line).join("\n");
const nextPrompt = lines.findIndex((line, index) => index > answerAt && /^›(?:[ \t]|$)/.test(line));
const box = codexBoxAt(lines);
const nextPrompt = lines.findIndex((line, index) => index > answerAt && (/^›(?:[ \t]|$)/.test(line) || (index === box && line.startsWith("»"))));
const responseLines = lines.slice(answerAt, nextPrompt < 0 ? undefined : nextPrompt);
// Codex's completed-turn footer is terminal chrome, after a blank line and before the composer.
const footer = responseLines.findIndex((line, index) => index > 0 && responseLines[index - 1]!.trim() === ""
Expand Down
131 changes: 131 additions & 0 deletions server/core/codex-held-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as client from '../herdr/client.ts';
import type { HerdrPane } from '../../shared/protocol.ts';
import { createSubmitText } from './composer.ts';
import { codexInputHeld } from '../dialogs/codex.ts';
import { registry } from '../dialogs/registry.ts';

// herdr reports Codex at rest under its own lists and fields, so a message for its composer would be pasted into them: the
// paste is dropped, and the Enter after it takes the row under the cursor (saving a model as the default for every new
// session), or in the transcript view rewinds the conversation. These are Codex 0.166's own rows, typed here by hand;
// nothing was said in them.
const STATUS = ' GPT-6-Astra ultra · /fixture';
const ROWS = ' ← for agents · ? for shortcuts';
const HELD: Record<string, string[]> = {
'/model': ['Select Model and Effort', ' 1. GPT-6.1-Sol Latest workhorse model.', '› 2. GPT-6-Astra (current) Frontier intelligence.', '↓', ' enter select · esc back'],
'its levels': ['Select Reasoning Level for GPT-6-Astra', '› 4. Extra high (current) Extra high reasoning depth', ' 5. More reasoning… Max and Ultra', '', ' enter default · s session · esc back'],
'its levels at Ultra': ['Select Reasoning Level for GPT-6-Astra', '› 5. More reasoning… (current) Max and Ultra', '', ' enter select · esc back'],
'its advanced levels': ['Advanced Reasoning', ' 1. Max', '› 2. Ultra (current)', '', ' enter apply · s session · esc back'],
'/permissions': ['Update Model Permissions', '› 1. Read Only', ' 2. Auto', '', ' enter select · esc back'],
'/review': ['Select a review preset', '› 1. Review against a base branch', '', ' enter select · esc back'],
'/theme': ['Select Syntax Theme', '› catppuccin-latte (current)', '↓', '', ' enter select · esc back'],
'/statusline': ['Configure Status Line', '› [x] model-with-reasoning', '↓', ' GPT-6-Astra ultra · /fixture · a thread', '', ' space toggle · ←/→ reorder · enter save · esc cancel'],
'/resume': ['Resume a previous session', '› Yesterday', '', ' enter resume esc exit ^c exit tab focus sort/filter ←/→ change option', ' ^o comfortable view ^t transcript ^e expand ↑/↓ browse'],
'/rename': ['Name thread', '› Type a name and press Enter', '', ' enter submit · esc back'],
'/experimental': ['Experimental features', '› [ ] Something new', '', ' space toggle · enter save · esc save/close'],
'/agent': ['Agents', '› main', '', ' ? help esc back ↑/↓ move enter open n new'],
'the transcript view': ['» Ask Codex to do anything', STATUS, 'Browsing transcript · ↑↓/jk scroll · ←→/hl prompts · ^t details · ↵ rewind · esc back'],
'/diff': ['diff --git a/a b/a', '+a line', '', ' q close'],
'a folder not trusted yet': ['Do you trust the contents of this directory?', '› 1. Yes', '', ' enter continue · esc back'],
};
const FREE: Record<string, string[]> = {
'its composer': ['› Ask Codex to do anything', STATUS, ROWS],
'its composer at Ultra': ['» Ask Codex to do anything', STATUS, ROWS],
'an empty one': ['»', STATUS, ROWS],
'a thread named as a key': ['› Ask Codex to do anything', ' GPT-6-Astra xhigh · /fixture · esc back', ROWS],
'a thread named so, the footer gone': ['› a draft', ' GPT-6-Astra ultra fast · /fixture · enter select'],
'such a thread on a model with a space in its name': ['› Ask Codex to do anything', ' GPT-6.1 Terra xhigh · /fixture · esc back', ROWS],
'one in the folder': ['› Ask Codex to do anything', ' GPT-6-Astra default · ~/esc back · Main [default]', ROWS],
'a draft of the words': ['» esc back', STATUS, ROWS],
'a draft of them, the footer gone': ['» enter select · esc back', STATUS],
'a working turn': ['• Working (1s • esc to interrupt)', '', '› Ask Codex to do anything', STATUS, ROWS],
'an answer quoting a list': ['• Press these:', ' enter select · esc back', '', '› Ask Codex to do anything', STATUS, ROWS],
'a warning beside the footer': ['› Ask Codex to do anything', STATUS, ' ← for agents · ? for shortcuts ⚠ 1 warning · f2 to view'],
'a blank screen': [''],
'a draft whose last line reads as keys, the footer gone': ['» Please follow these steps', ' enter select · esc back', STATUS],
'a status row wrapped onto a thread named as a key': ['› Ask Codex to do anything', ' GPT-6-Astra ultra · /fixture ·', ' esc back', ROWS],
};
const screen = (rows: string[]) => [' >_ OpenAI Codex (v0.166.0)', ' /fixture', '', ...rows, '', ''].join('\n');

describe('codexInputHeld', () => {
for (const [name, rows] of Object.entries(HELD)) it(`holds under ${name}`, () => expect(codexInputHeld(screen(rows))).toBe(true));
for (const [name, rows] of Object.entries(FREE)) it(`is free with ${name}`, () => expect(codexInputHeld(screen(rows))).toBe(false));
it('reads divider rows and colour as nothing', () => expect(codexInputHeld(`\u001b[1mSelect Model\u001b[0m\n \u001b[2menter select\u001b[0m · \u001b[2mesc back\u001b[0m\n${'─'.repeat(40)}\n`)).toBe(true));
});

describe('registry.holdsInput', () => {
it('is for Codex alone: the other agents keep what their own readers say', () => {
const list = screen(HELD['/permissions']!);
expect(registry.holdsInput('codex', list)).toBe(true);
for (const agent of ['pi', 'claude', 'omp']) expect(registry.holdsInput(agent, list)).toBe(false);
});
it('keeps upstream\'s card for a Codex list: the guard is not one of its waits', () => {
expect(registry.waits('codex', screen(HELD['/permissions']!))).toBe(false);
});
});

describe('a message sent to a Codex pane', () => {
const pane: HerdrPane = { pane_id: 'codex-held', workspace_id: 'w', tab_id: 't', terminal_id: 'terminal',
cwd: '/fixture', focused: false, revision: 0, agent: 'codex', agent_status: 'idle' };
const submitter = () => createSubmitText({
options: { submitDelayMs: 0 }, lastTyped: new Map(), attachments: new Map(), terminalAttach: async () => true,
SUBMIT_DEADLINE_MS: 1000, TYPED_SETTLE_MS: 0, DRAFT_CLEAR_WAIT_MS: 0, DRAFT_CLEAR_POLL_MS: 0, SUBMIT_DELAY_MS: 0,
});
beforeEach(() => spyOn(client, 'paneProcessInfo').mockResolvedValue(undefined));
afterEach(() => mock.restore());
const spies = (rows: string[], agent = 'codex') => {
spyOn(client, 'paneGet').mockResolvedValue({ ...pane, agent });
return {
read: spyOn(client, 'paneRead').mockResolvedValue({ text: screen(rows) } as Awaited<ReturnType<typeof client.paneRead>>),
prompt: spyOn(client, 'agentPrompt').mockResolvedValue(),
typed: spyOn(client, 'paneSendText').mockResolvedValue(),
keys: spyOn(client, 'paneSendKeys').mockResolvedValue(),
};
};

it('is refused under any of its screens, with nothing typed', async () => {
for (const [name, rows] of Object.entries(HELD)) {
const { prompt, typed, keys } = spies(rows);
const failed = await submitter()(pane.pane_id, 'Carry on without me', 'Carry on without me', Date.now()).then(() => null, (error: unknown) => error);
expect([name, (failed as client.HerdrError | null)?.code]).toEqual([name, 'agent_blocked']);
expect(prompt).not.toHaveBeenCalled();
expect(typed).not.toHaveBeenCalled();
expect(keys).not.toHaveBeenCalled();
mock.restore();
spyOn(client, 'paneProcessInfo').mockResolvedValue(undefined);
}
});

it('types nothing past a collapsed queue once one of its screens opened over it', async () => {
const queue = ['• Queued follow-up inputs', ' ? 1 question', ' alt+↑ to answer'];
const { read, prompt, typed, keys } = spies([...queue, ...FREE['its composer']!]);
spyOn(client, 'paneGet').mockResolvedValue({ ...pane, agent_status: 'blocked' });
prompt.mockRejectedValue(new client.HerdrError('agent_blocked', 'blocked'));
// free when first read; the transcript view opened before the refusal's own look
read.mockResolvedValueOnce({ text: screen([...queue, ...FREE['its composer']!]) } as Awaited<ReturnType<typeof client.paneRead>>)
.mockResolvedValue({ text: screen([...queue, ...HELD['the transcript view']!]) } as Awaited<ReturnType<typeof client.paneRead>>);
const failed = await submitter()(pane.pane_id, 'Carry on', 'Carry on', Date.now()).then(() => null, (error: unknown) => error);
expect((failed as client.HerdrError | null)?.code).toBe('agent_blocked');
expect(typed).not.toHaveBeenCalled();
expect(keys).not.toHaveBeenCalled();
});

it('goes to the agent at its composer', async () => {
const { prompt } = spies(FREE['its composer at Ultra']!);
await submitter()(pane.pane_id, 'Carry on without me', 'Carry on without me', Date.now());
expect(prompt).toHaveBeenCalledTimes(1);
});

it('reads no screen for another agent', async () => {
const { prompt, read } = spies(HELD['/permissions']!, 'pi');
await submitter()(pane.pane_id, 'Carry on', 'Carry on', Date.now());
expect(prompt).toHaveBeenCalledTimes(1);
expect(read).not.toHaveBeenCalled();
});

it('leaves the terminal\'s own input line alone: there it stands in for the keyboard', async () => {
const { read } = spies(HELD['/permissions']!);
await submitter()(pane.pane_id, 'j', 'j', Date.now(), true);
expect(read).not.toHaveBeenCalledWith(expect.objectContaining({ source: 'detection', paneId: pane.pane_id }));
});
});
13 changes: 11 additions & 2 deletions server/core/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ export async function blockedOnlyByCodexQueue(paneId: string): Promise<boolean>
}), "codex");
if (!(await codex())) return false;
// A collapsed queue in scrollback must not bypass an approval on the live screen.
if (!codexQuestionsCollapsed((await paneRead({ paneId, source: "detection", format: "text" })).text)) return false;
// nor may one of Codex's own lists or fields that opened since (codexInputHeld): the raw typing below would land in it
const screen = (await paneRead({ paneId, source: "detection", format: "text" })).text;
if (!codexQuestionsCollapsed(screen) || registry.holdsInput("codex", screen)) return false;
// the screen was Codex's only if Codex still runs once it is read: one that exited meanwhile left a shell to type into
return await codex();
}
Expand Down Expand Up @@ -331,7 +333,7 @@ export function createSubmitText({ options, lastTyped, attachments, terminalAtta
*
* An agent gets it through herdr's agent.prompt (the paste, then Enter 300ms later),
* which refuses while the agent waits for an answer: the message is not typed into its
* menu. A pane without an agent in front gets `payload`, shaped for its own paste mode,
* menu, nor into a Codex screen herdr does not report (codexInputHeld). A pane without an agent in front gets `payload`, shaped for its own paste mode,
* through send_text, then Enter SUBMIT_DELAY_MS later; both return once the pane has
* the bytes, so the pane sees the whole gap. So does a Codex "blocked" only by questions
* waiting collapsed in its queue: its main prompt still takes the message.
Expand Down Expand Up @@ -524,6 +526,13 @@ export function createSubmitText({ options, lastTyped, attachments, terminalAtta
// the last look waited on herdr: the device may have lost its access meanwhile
inTime();
}
// herdr reports Codex at rest under its own lists and fields, so agent.prompt would paste into them, and
// its Enter would take a row (codexInputHeld): refused as a menu herdr does report is, the message kept
if (agent === "codex") {
const held = registry.holdsInput(agent, (await paneRead({ paneId, source: "detection", format: "text" })).text);
inTime();
if (held) throw new HerdrError("agent_blocked", "The agent is waiting for an answer in the terminal");
}
if (pane) command = prepareCommandSend(pane, text, beforeScreen);
// where that screen sits in the history (outcome.ts scrolledAnchor): the row read before the screen, kept
// once a read after it agrees, so nothing scrolled in between. That read only records the send, so it runs
Expand Down
2 changes: 2 additions & 0 deletions server/core/omp-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ beforeEach(() => {
? { pane: { pane_id: "w:p1", workspace_id: "w", tab_id: "w:t1", terminal_id: "w:p1", cwd: root, focused: false, revision: 0, agent, ...session } }
: request.method === "pane.process_info" ? { process_info: { foreground_processes: ompShown ? [{ pid: ompPid, argv: ompArgv, cwd: ompCwd }] : [] } }
: request.method === "agent.prompt" || request.method === "pane.send_keys" || request.method === "pane.send_text" ? {}
// a Codex send reads the screen for what holds its input (codexInputHeld): this one holds nothing
: request.method === "pane.read" && agent === "codex" ? { read: { text: "" } }
: null;
if (result === null) throw new Error(`unexpected RPC ${request.method}`);
socket.end(`${JSON.stringify({ id: request.id, result })}\n`);
Expand Down
Loading
Loading