From baa23322bfac8aaab4ac8004ca0916251609e4a1 Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:23:12 +0200 Subject: [PATCH 1/7] fix(agent-prompt): do not paste onto residual composer text sendTerminalAgentPrompt pasted into the agent composer without looking at it, so leftover text (a human draft or an earlier attempt whose Enter did not land) was concatenated with the new prompt and submitted as one garbled line. Before pasting, read the pane's headless emulator and classify the composer's typed text, with dim suggestions excluded on the cursor row and on the rows they wrap onto. Refuse other text with agent_prompt_composer_not_empty. Submit Orca's own byte-identical earlier paste with Enter alone, but only while its turn start is unobserved. Read a paste whose turn start was observed as a late repaint and paste the next prompt as usual. Keep today's behavior when the composer is empty or unrecognized. The pre-write check (beforeWrite) awaits, so before an Enter-only submit re-read the composer synchronously: if any PTY output arrived for the screen model since it was judged, or it no longer reads as that parked paste, refuse with agent_prompt_composer_not_empty instead of sending a bare Enter. Fixes #15976 Co-Authored-By: Claude Opus 5.5 --- .../runtime/agent-prompt-composer-ledger.ts | 71 +++ .../agent-prompt-composer-residue.test.ts | 547 ++++++++++++++++++ .../runtime/agent-prompt-composer-residue.ts | 62 ++ ...ca-runtime-controller-knows-pty-is-live.ts | 6 +- ...rca-runtime-write-terminal-agent-prompt.ts | 142 ++++- src/main/runtime/rpc/errors.ts | 1 + src/shared/terminal-composer-draft.test.ts | 20 + src/shared/terminal-composer-draft.ts | 27 +- 8 files changed, 854 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/agent-prompt-composer-ledger.ts create mode 100644 src/main/runtime/agent-prompt-composer-residue.test.ts create mode 100644 src/main/runtime/agent-prompt-composer-residue.ts diff --git a/src/main/runtime/agent-prompt-composer-ledger.ts b/src/main/runtime/agent-prompt-composer-ledger.ts new file mode 100644 index 000000000000..4fa50586644a --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-ledger.ts @@ -0,0 +1,71 @@ +import type { AgentPromptOwnPaste } from './agent-prompt-composer-residue' + +// Why: right after Orca's own Enter the emulator can still paint the prompt it just submitted. +const AGENT_PROMPT_COMPOSER_SETTLE_MS = 1_000 +// Why: a turn start proves the agent took the prompt, so its text still on screen is a late repaint; +// long after that, the same text is someone's recalled draft again. +const AGENT_PROMPT_LANDED_REPAINT_MS = 10_000 + +/** Per pane: Orca's own last prompt paste and Enter, the evidence a composer verdict rests on. */ +export class AgentPromptComposerLedger { + private readonly submittedAtByPtyId = new Map() + private readonly lastPasteByPtyId = new Map< + string, + { generation: number; payload: string; landedAt: number | null } + >() + + markSubmitted(ptyId: string, generation: number): void { + const now = Date.now() + // Why prune here: only Enters inside the settle window matter, so the map stays tiny. + for (const [id, submitted] of this.submittedAtByPtyId) { + if (now - submitted.at >= AGENT_PROMPT_COMPOSER_SETTLE_MS) { + this.submittedAtByPtyId.delete(id) + } + } + this.submittedAtByPtyId.set(ptyId, { generation, at: now }) + } + + /** How long a composer read must still wait for Orca's own Enter to clear the screen. */ + settleMsLeft(ptyId: string, generation: number): number { + const submitted = this.submittedAtByPtyId.get(ptyId) + if (submitted?.generation !== generation) { + return 0 + } + return Math.max(0, submitted.at + AGENT_PROMPT_COMPOSER_SETTLE_MS - Date.now()) + } + + rememberPaste( + ptyId: string, + generation: number, + payload: string, + isLivePty: (ptyId: string) => boolean + ): void { + // Why prune here: only live panes can be judged again, so the map stays one entry per pane. + for (const id of this.lastPasteByPtyId.keys()) { + if (!isLivePty(id)) { + this.lastPasteByPtyId.delete(id) + } + } + this.lastPasteByPtyId.set(ptyId, { generation, payload, landedAt: null }) + } + + markLanded(ptyId: string, generation: number, payload: string): void { + const paste = this.lastPasteByPtyId.get(ptyId) + if (paste?.generation === generation && paste.payload === payload) { + paste.landedAt = Date.now() + } + } + + getOwnPaste(ptyId: string, generation: number): AgentPromptOwnPaste | null { + const paste = this.lastPasteByPtyId.get(ptyId) + if (paste?.generation !== generation) { + return null + } + if (paste.landedAt === null) { + return { payload: paste.payload, landed: false } + } + return Date.now() - paste.landedAt < AGENT_PROMPT_LANDED_REPAINT_MS + ? { payload: paste.payload, landed: true } + : null + } +} diff --git a/src/main/runtime/agent-prompt-composer-residue.test.ts b/src/main/runtime/agent-prompt-composer-residue.test.ts new file mode 100644 index 000000000000..9c2c323af6cc --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -0,0 +1,547 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + buildAgentPromptPasteBytes +} from '../../shared/agent-prompt-injection' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' +import { classifyAgentPromptComposerResidue } from './agent-prompt-composer-residue' +import { createAgentPromptSubmissionRuntime } from './agent-prompt-submission-runtime-test-fixture' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/composer-residue', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/composer-residue', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const FRAME = '─'.repeat(40) +const WORKING_TITLE = '\x1b]0;Codex working\x07' + +/** A Claude-shaped composer: a framed `❯` line on row 4, cursor after the typed text. */ +function composerFrame(typed: string, dimSuggestion = ''): string { + const suggestion = dimSuggestion ? `\x1b[2m${dimSuggestion}\x1b[22m` : '' + return ( + `\x1b[2J\x1b[HClaude Code\r\n\r\n${FRAME}\r\n❯ ${typed}${suggestion}\r\n${FRAME}\r\n` + + `\x1b[4;${3 + typed.length}H` + ) +} + +function composerContext(typed: string, rawAfterCursor = ''): TerminalCursorContext { + return { + rows: [FRAME, `❯ ${typed}${rawAfterCursor}`], + typedRows: [FRAME, `❯ ${typed}`], + promptGlyphBoldRows: [false, false], + rowsBelow: [FRAME], + typedRowsBelow: [FRAME], + beforeCursor: `❯ ${typed}`, + afterCursor: '', + rawAfterCursor, + cursorHidden: false, + cursorViewportRow: 3 + } +} + +describe('classifyAgentPromptComposerResidue', () => { + it('reports other typed text as foreign', () => { + expect( + classifyAgentPromptComposerResidue( + composerContext('/hel'), + buildAgentPromptPasteBytes('Responda apenas: OK4') + ) + ).toBe('foreign') + }) + + it('recognizes its own parked paste, whitespace aside', () => { + const payload = buildAgentPromptPasteBytes('review this\nchange') + expect( + classifyAgentPromptComposerResidue(composerContext('review this change'), payload, { + payload, + landed: false + }) + ).toBe('same-prompt') + }) + + it('compares the typed lead line with the pasted prompt', () => { + const payload = buildAgentPromptPasteBytes('review this', 'Orca task:') + expect( + classifyAgentPromptComposerResidue(composerContext('Orca task: review this'), payload, { + payload, + landed: false + }) + ).toBe('same-prompt') + }) + + it('does not take matching words it did not paste for its own prompt', () => { + const parked = buildAgentPromptPasteBytes('if ready:\ndeploy()') + const requested = buildAgentPromptPasteBytes('if ready:\n deploy()') + expect( + classifyAgentPromptComposerResidue(composerContext('if ready: deploy()'), requested) + ).toBe('foreign') + // Why: the screen drops indentation, so only a byte-identical earlier paste is "ours". + expect( + classifyAgentPromptComposerResidue(composerContext('if ready: deploy()'), requested, { + payload: parked, + landed: false + }) + ).toBe('foreign') + }) + + it('reads its own landed prompt, still painted, as an empty composer', () => { + const first = buildAgentPromptPasteBytes('first task') + const landed = { payload: first, landed: true } + expect( + classifyAgentPromptComposerResidue( + composerContext('first task'), + buildAgentPromptPasteBytes('second task'), + landed + ) + ).toBe('none') + expect(classifyAgentPromptComposerResidue(composerContext('first task'), first, landed)).toBe( + 'none' + ) + expect(classifyAgentPromptComposerResidue(composerContext('/hel'), first, landed)).toBe( + 'foreign' + ) + }) + + it('does not count a dim cursor-right suggestion as input', () => { + expect( + classifyAgentPromptComposerResidue( + composerContext('', 'proceed with the release'), + buildAgentPromptPasteBytes('review this') + ) + ).toBe('none') + }) + + it('does not count a dim suggestion wrapped below the cursor as input', () => { + // The shared detector's own wrapped-suggestion fixture (terminal-composer-draft.test.ts). + expect( + classifyAgentPromptComposerResidue( + { + rows: ['────────', '❯ proceed with the release'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsBelow: [' and close the pull request', '────────'], + typedRowsBelow: ['', '────────'], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed with the release', + cursorHidden: false, + cursorViewportRow: 8 + }, + buildAgentPromptPasteBytes('review this') + ) + ).toBe('none') + }) + + it('still reads typed text in a Codex composer whose footer is dim', () => { + expect( + classifyAgentPromptComposerResidue( + { + rows: ['› review the change'], + typedRows: ['› review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['', ' Release train'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [false, false], + beforeCursor: '› review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }, + buildAgentPromptPasteBytes('another task') + ) + ).toBe('foreign') + }) + + it('treats an empty or unrecognized composer as nothing to protect', () => { + const payload = buildAgentPromptPasteBytes('review this') + expect(classifyAgentPromptComposerResidue(composerContext(''), payload)).toBe('none') + expect(classifyAgentPromptComposerResidue(null, payload)).toBe('none') + expect( + classifyAgentPromptComposerResidue( + { ...composerContext('/hel'), rows: ['plain output', '❯ /hel'] }, + payload + ) + ).toBe('none') + }) +}) + +describe('agent prompt composer residue (#15976)', () => { + afterEach(() => vi.useRealTimers()) + + it('refuses to paste onto text already in the composer', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime(() => undefined) + runtime.onPtyData('pty-prompt', composerFrame('/hel'), Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'Responda apenas: OK4', { + inputKind: 'driving' + }) + const rejected = expect(submission).rejects.toThrow('agent_prompt_composer_not_empty') + await vi.runAllTimersAsync() + + await rejected + expect(writes).toEqual([]) + }) + + it('submits its own parked prompt with Enter alone instead of pasting it twice', async () => { + vi.useFakeTimers() + let enters = 0 + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', composerFrame('review this change'), Date.now()) + } else if (data === '\r' && ++enters > 1) { + // The first Enter is lost, so the prompt stays parked; the retry's Enter lands. + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + const stalled = expect(first).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + await stalled + const retry = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + + await expect(retry).resolves.toMatchObject({ accepted: true, bytesWritten: 1 }) + expect(writes.slice(2)).toEqual(['\r']) + }) + + it('sends only Enter for a parked prompt when the agent joins submit to the paste', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + // The Enter that rode in the paste write is lost. + runtime.onPtyData('pty-prompt', composerFrame('review this change'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + }, + 'omp' + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + const stalled = expect(first).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + await stalled + const retry = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + + await expect(retry).resolves.toMatchObject({ accepted: true, bytesWritten: 1 }) + expect(writes.slice(1)).toEqual(['\r']) + }) + + it.each([ + ['leaves the parked prompt alone', ''], + ['replaces the parked prompt', 'something else entirely'] + ])( + 'judges a parked prompt again after a pre-write check that %s', + async (_label, replacement) => { + vi.useFakeTimers() + let enters = 0 + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', composerFrame('review this change'), Date.now()) + } else if (data === '\r' && ++enters > 1) { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + const stalled = expect(first).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + await stalled + const retry = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', + beforeWrite: async () => { + // Another writer to the pane changes the composer while the pre-write check awaits. + if (replacement) { + runtime.onPtyData('pty-prompt', composerFrame(replacement), Date.now()) + } + await Promise.resolve() + } + }) + const settled = Promise.allSettled([retry]) + await vi.runAllTimersAsync() + const [result] = await settled + + if (replacement) { + expect(result).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) + }) + expect(writes.slice(2)).toEqual([]) + } else { + expect(result).toMatchObject({ + status: 'fulfilled', + value: { accepted: true, bytesWritten: 1 } + }) + expect(writes.slice(2)).toEqual(['\r']) + } + } + ) + + it('refuses a parked prompt when output arrives during the pre-write check', async () => { + vi.useFakeTimers() + let enters = 0 + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', composerFrame('review this change'), Date.now()) + } else if (data === '\r' && ++enters > 1) { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + const stalled = expect(first).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + await stalled + const retry = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', + beforeWrite: async () => { + // A title leaves the composer as it was; only the write chain shows the output. + runtime.onPtyData('pty-prompt', '\x1b]0;Claude Code\x07', Date.now()) + await Promise.resolve() + } + }) + const settled = Promise.allSettled([retry]) + await vi.runAllTimersAsync() + const [result] = await settled + + expect({ result, writes: writes.slice(2) }).toMatchObject({ + result: { + status: 'rejected', + reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) + }, + writes: [] + }) + }) + + it('refuses text it did not paste even when the pre-write check swaps it', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame('review this change'), Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', + beforeWrite: async () => { + runtime.onPtyData('pty-prompt', composerFrame('something else entirely'), Date.now()) + await Promise.resolve() + } + }) + const rejected = expect(submission).rejects.toThrow('agent_prompt_composer_not_empty') + await vi.runAllTimersAsync() + + await rejected + expect(writes).toEqual([]) + }) + + it('refuses a parked earlier paste that differs from this prompt only in whitespace', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + // The composer shows continuation rows indented its own way, not the prompt's. + runtime.onPtyData( + 'pty-prompt', + `\x1b[2J\x1b[HClaude Code\r\n\r\n${FRAME}\r\n❯ if ready:\r\n deploy()\r\n${FRAME}\r\n` + + '\x1b[5;11H', + Date.now() + ) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'if ready:\ndeploy()', { + inputKind: 'driving' + }) + const stalled = expect(first).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + await stalled + const writesBefore = writes.length + const indented = runtime.sendTerminalAgentPrompt(handle, 'if ready:\n deploy()', { + inputKind: 'driving' + }) + const rejected = expect(indented).rejects.toThrow('agent_prompt_composer_not_empty') + await vi.runAllTimersAsync() + + await rejected + expect(writes.slice(writesBefore)).toEqual([]) + }) + + it('pastes as before while the screen model is still hydrating', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame('/hel'), Date.now()) + runtime['headlessHydrationState'].set('pty-prompt', 'pending') + + const submission = runtime.sendTerminalAgentPrompt(handle, 'Responda apenas: OK4', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data.includes('Responda apenas: OK4'))).toHaveLength(1) + }) + + it('pastes over a dim suggestion in an otherwise empty composer', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame('', 'proceed with the release'), Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toHaveLength(1) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('lets a just-submitted prompt clear before judging the next one', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('first task')) { + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // The agent repaints its emptied composer a moment after taking the Enter. + setTimeout(() => runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()), 800) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + // Why concurrent: submissions to one pane are serialized, so the second is judged right + // after the first Enter, while the screen still shows the first prompt. + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + const second = runtime.sendTerminalAgentPrompt(handle, 'second task', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + + await expect(first).resolves.toMatchObject({ accepted: true }) + await expect(second).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data.includes('second task'))).toHaveLength(1) + }) + + it('pastes over a dim suggestion that wraps below the cursor', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()) + } + } + ) + runtime.onPtyData( + 'pty-prompt', + `\x1b[2J\x1b[HClaude Code\r\n\r\n${FRAME}\r\n❯ \x1b[2mproceed with the release\x1b[22m\r\n` + + `\x1b[2m and close the pull request\x1b[22m\r\n${FRAME}\r\n\x1b[4;3H`, + Date.now() + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toHaveLength(1) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it.each([ + ['a different', 'second task'], + ['an identical', 'first task'] + ])( + 'pastes %s next prompt when the agent repaints its emptied composer late', + async (_label, nextPrompt) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + const pasted = ['first task', 'second task'].find((text) => data.includes(text)) + if (pasted) { + runtime.onPtyData('pty-prompt', composerFrame(pasted), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // The turn starts at once, but the emptied composer is painted only later. + setTimeout(() => runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()), 1_500) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + const next = runtime.sendTerminalAgentPrompt(handle, nextPrompt, { inputKind: 'driving' }) + await vi.runAllTimersAsync() + + await expect(first).resolves.toMatchObject({ accepted: true }) + await expect(next).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toHaveLength( + 2 + ) + expect(writes.filter((data) => data === '\r')).toHaveLength(2) + } + ) +}) diff --git a/src/main/runtime/agent-prompt-composer-residue.ts b/src/main/runtime/agent-prompt-composer-residue.ts new file mode 100644 index 000000000000..5d26caf499a0 --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-residue.ts @@ -0,0 +1,62 @@ +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_BRACKETED_PASTE_START +} from '../../shared/agent-prompt-injection' +import { + detectTerminalComposerDraft, + type TerminalCursorContext +} from '../../shared/terminal-composer-draft' + +/** + * The composer holds text that is not this prompt, or its parked copy changed before Orca could + * submit it; nothing was written. + */ +export const AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR = 'agent_prompt_composer_not_empty' + +/** + * What an agent's composer holds before Orca pastes a prompt into it. + * - `none`: empty, or no composer Orca recognizes (today's behavior: paste). + * - `same-prompt`: Orca's own earlier paste of these exact bytes is still parked, e.g. an attempt + * whose Enter did not land. + * - `foreign`: other typed text, which a paste would silently concatenate onto (#15976). + */ +export type AgentPromptComposerResidue = 'none' | 'same-prompt' | 'foreign' + +/** Orca's own last paste into this composer; `landed` once the agent's turn start confirmed it. */ +export type AgentPromptOwnPaste = { payload: string; landed: boolean } + +export function classifyAgentPromptComposerResidue( + context: TerminalCursorContext | null | undefined, + pastePayload: string, + ownPaste?: AgentPromptOwnPaste | null +): AgentPromptComposerResidue { + if (!context) { + return 'none' + } + // Why typed text only: a dim suggestion is not input; typing replaces it. + const draft = detectTerminalComposerDraft(context, { typedOnly: true }) + const residue = normalizeComposerText(draft?.text ?? '') + if (!residue) { + return 'none' + } + if (!ownPaste || residue !== normalizeComposerText(stripPasteFrame(ownPaste.payload))) { + return 'foreign' + } + // Why: the agent already took that prompt; the screen has not yet repainted its emptied composer. + if (ownPaste.landed) { + return 'none' + } + // Why: the screen drops indentation and line breaks, so only a byte-identical paste is this prompt. + return ownPaste.payload === pastePayload ? 'same-prompt' : 'foreign' +} + +function stripPasteFrame(payload: string): string { + return payload + .replaceAll(AGENT_PROMPT_BRACKETED_PASTE_START, '') + .replaceAll(AGENT_PROMPT_BRACKETED_PASTE_END, '') +} + +// Why: the composer re-wraps and re-indents what it renders, so only the words are comparable. +function normalizeComposerText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 82368a663a59..99b20f61e350 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -204,7 +204,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso }) } ) - const bytesWritten = Buffer.byteLength(payload, 'utf8') + delivery.submits + const bytesWritten = + (delivery.pasted ? Buffer.byteLength(payload, 'utf8') : 0) + delivery.submits return { handle, accepted: true, @@ -233,7 +234,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso promptForSchedule: prompt }) }) - const bytesWritten = Buffer.byteLength(payload, 'utf8') + delivery.submits + const bytesWritten = + (delivery.pasted ? Buffer.byteLength(payload, 'utf8') : 0) + delivery.submits return { handle, accepted: true, diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 4f4839678ace..b8bde1954085 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -7,6 +7,12 @@ import { waitForAgentPromptDelay, waitForAgentPromptPromise } from './orca-runtime-core' +import { + AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR, + classifyAgentPromptComposerResidue, + type AgentPromptComposerResidue +} from './agent-prompt-composer-residue' +import { AgentPromptComposerLedger } from './agent-prompt-composer-ledger' import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS, AGENT_PROMPT_SUBMIT, @@ -24,13 +30,100 @@ import { import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { + private readonly agentPromptComposerLedger = new AgentPromptComposerLedger() + + private getJudgeableHeadlessTerminal(ptyId: string) { + const state = this.headlessTerminals.get(ptyId) + // Why: a provider-restored suffix or a pending hydration is not the whole screen, and doubt + // never blocks a write. + if ( + !state || + this.providerSnapshotPreferredPtys.has(ptyId) || + this.headlessHydrationState.get(ptyId) === 'pending' + ) { + return null + } + return state + } + + private async readAgentPromptComposerResidue( + ptyId: string, + generation: number, + pastePayload: string, + signal?: AbortSignal + ): Promise<{ residue: AgentPromptComposerResidue; parsedThrough: Promise | null }> { + const state = this.getJudgeableHeadlessTerminal(ptyId) + if (!state) { + return { residue: 'none', parsedThrough: null } + } + const settleMs = this.agentPromptComposerLedger.settleMsLeft(ptyId, generation) + if (settleMs > 0) { + await waitForAgentPromptDelay(settleMs, signal) + } + const parsedThrough = state.writeChain + await waitForAgentPromptPromise(parsedThrough, signal) + if ( + this.headlessTerminals.get(ptyId) !== state || + this.getPtyLifecycleGeneration(ptyId) !== generation + ) { + return { residue: 'none', parsedThrough: null } + } + return { + residue: classifyAgentPromptComposerResidue( + state.emulator.getCursorLineContext(), + pastePayload, + this.agentPromptComposerLedger.getOwnPaste(ptyId, generation) + ), + parsedThrough + } + } + + /** Synchronous re-read: is the composer still exactly the parked prompt judged before the awaits? */ + private isAgentPromptStillParked( + ptyId: string, + generation: number, + pastePayload: string, + parsedThrough: Promise | null + ): boolean { + const state = this.getJudgeableHeadlessTerminal(ptyId) + // Why: a newer write-chain link means PTY output arrived since the read, parsed or not yet. + if ( + !state || + state.writeChain !== parsedThrough || + this.getPtyLifecycleGeneration(ptyId) !== generation + ) { + return false + } + return ( + classifyAgentPromptComposerResidue( + state.emulator.getCursorLineContext(), + pastePayload, + this.agentPromptComposerLedger.getOwnPaste(ptyId, generation) + ) === 'same-prompt' + ) + } + protected async writeTerminalAgentPrompt( handle: string, ptyId: string, generation: number, pastePayload: string, options: RuntimeAgentPromptWriteOptions - ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { + ): Promise<{ submits: number; pasted: boolean; prompt?: RuntimeTerminalPromptDelivery }> { + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + // Why (#15976): a paste concatenates onto whatever the composer holds, so a retry after an + // unobserved submit corrupted both prompts. Refuse foreign text; re-submit our own parked prompt. + const composer = await this.readAgentPromptComposerResidue( + ptyId, + generation, + pastePayload, + options.signal + ) + if (composer.residue === 'foreign') { + throw new Error(AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR) + } + const promptAlreadyParked = composer.residue === 'same-prompt' assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) @@ -40,14 +133,16 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // OMP treats a large bracketed paste as a menu unless submit arrives in the same PTY write. // Once a foreground agent is known, it is the process that will consume the bytes; // launchAgent is only the fallback during startup before process detection settles. - const submitWithPaste = agentPromptSubmitJoinsPasteFrame( - pty?.foregroundAgent ?? pty?.launchAgent - ) - const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') + // A parked prompt needs only its Enter, which then goes out as the first and only write. + const submitWithPaste = + promptAlreadyParked || + agentPromptSubmitJoinsPasteFrame(pty?.foregroundAgent ?? pty?.launchAgent) + const pasteByteLength = promptAlreadyParked ? 0 : Buffer.byteLength(pastePayload, 'utf8') const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) // Why no gate for a ready composer: a live Claude never settles it, so Enter always waited out // its 8 s cap, where the desktop's own paste submitted in about 2 s. - const renderGate = options.composerReady + const renderGate = + promptAlreadyParked || options.composerReady ? null : this.createAgentPromptRenderGate(ptyId, pasteIngestMs) const waitTextCache: AgentPromptWaitTextCache = {} @@ -64,13 +159,29 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso permissionBaseline, this.getAgentPromptActivity(handle, ptyId) ) + // Why: beforeWrite awaited, and Enter alone submits whatever the composer holds by now. + if ( + promptAlreadyParked && + !this.isAgentPromptStillParked(ptyId, generation, pastePayload, composer.parsedThrough) + ) { + throw new Error(AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR) + } // Keep the bracketed paste frame in one PTY write; Claude's composer can drop the // beginning when a large frame is split into independently processed chunks. renderGate?.arm() - const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload + const pasteWrite = promptAlreadyParked ? '' : pastePayload + const initialWrite = submitWithPaste ? pasteWrite + AGENT_PROMPT_SUBMIT : pasteWrite if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { throw new Error('terminal_not_writable') } + if (!promptAlreadyParked) { + this.agentPromptComposerLedger.rememberPaste(ptyId, generation, pastePayload, (id) => + this.ptysById.has(id) + ) + } + if (submitWithPaste) { + this.agentPromptComposerLedger.markSubmitted(ptyId, generation) + } } catch (error) { renderGate?.dispose() throw error @@ -117,11 +228,13 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + this.agentPromptComposerLedger.markSubmitted(ptyId, generation) } const submits = options.composerReady && !submitWithPaste ? 1 + (await this.resubmitAgentPromptAfterRetryDelay(ptyId, generation, options)) : 1 + const pasted = !promptAlreadyParked const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) if (!options.acceptQueued || !options.requestId) { await verifyAgentPromptSubmission({ @@ -130,7 +243,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso timeoutMs: effectTimeoutMs, signal: options.signal }) - return { submits } + // Why: an idle agent passes only on a turn start; a working one may pass on output alone. + if (baseline.status !== 'working') { + this.agentPromptComposerLedger.markLanded(ptyId, generation, pastePayload) + } + return { submits, pasted } } const binding = this.getTerminalPromptRequestBinding(handle) const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent @@ -154,7 +271,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const checkpoint: RuntimeTerminalSend = { handle, accepted: true, - bytesWritten: Buffer.byteLength(pastePayload, 'utf8') + 1, + bytesWritten: pasteByteLength + 1, prompt: inputAccepted } options.onInputAccepted?.(checkpoint) @@ -162,7 +279,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // receipt; they must not fail a Dispatch merely because Orca cannot prove // submission through hooks. if (!settlementAgent) { - return { submits, prompt: inputAccepted } + return { submits, pasted, prompt: inputAccepted } } this.registerAgentPromptRequest( ptyId, @@ -189,8 +306,10 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) + this.agentPromptComposerLedger.markLanded(ptyId, generation, pastePayload) return { submits, + pasted, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] @@ -198,12 +317,13 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - return { submits, prompt: inputAccepted } + return { submits, pasted, prompt: inputAccepted } } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { submits, + pasted, prompt: { ...inputAccepted, observation: 'permission' } } } diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 3c8dd53f5c22..afbc8c505e6a 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -82,6 +82,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_tab_not_found', 'terminal_tab_pinned', 'agent_prompt_blocked', + 'agent_prompt_composer_not_empty', 'agent_prompt_stalled', 'no_active_terminal', 'repo_not_found', diff --git a/src/shared/terminal-composer-draft.test.ts b/src/shared/terminal-composer-draft.test.ts index 0dc856ffd226..b42afd8cb8fc 100644 --- a/src/shared/terminal-composer-draft.test.ts +++ b/src/shared/terminal-composer-draft.test.ts @@ -146,6 +146,26 @@ describe('detectTerminalComposerDraft', () => { }) }) + it('reads no draft from dimmed suggestion rows when asked for typed text only', () => { + expect( + detectTerminalComposerDraft( + { + rows: ['────────', '❯ proceed with the release'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsBelow: [' and close the pull request', '────────'], + typedRowsBelow: ['', '────────'], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed with the release', + cursorHidden: false, + cursorViewportRow: 8 + }, + { typedOnly: true } + ) + ).toBeNull() + }) + it('recognizes the bold Codex ultra composer glyph', () => { expect( detectTerminalComposerDraft({ diff --git a/src/shared/terminal-composer-draft.ts b/src/shared/terminal-composer-draft.ts index e33fe0cd8434..7db01f00eb2d 100644 --- a/src/shared/terminal-composer-draft.ts +++ b/src/shared/terminal-composer-draft.ts @@ -22,6 +22,11 @@ export type TerminalComposerDraft = { promptGlyph: '❯' | '›' | '»' } +export type TerminalComposerDraftOptions = { + /** Read typed text only: a dim suggestion, on the cursor row or wrapped below it, is not input. */ + typedOnly?: boolean +} + type TerminalComposerMatch = TerminalComposerDraft & { placeholder: boolean } const COMPOSER_FRAME_LINE = /^[─━-]{8,}\s*$/ @@ -30,7 +35,8 @@ const CODEX_FOOTER_LINE = /^\s*(?:gpt-\S+|o\d\S*)\s+[·•]\s+\S.*$/i function composerContinuationRows( context: TerminalCursorContext, afterCursor: string, - codexFooterIndex: number + codexFooterIndex: number, + typedOnly: boolean ): { text: string; wrapped: boolean }[] { if (!afterCursor.trim() && !context.typedRowsBelow.some((row) => row.trim())) { return [] @@ -54,12 +60,13 @@ function composerContinuationRows( if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) { break } - if (!raw.trim() && !hasTypedContinuationAfter[index]) { + const typed = context.typedRowsBelow[index] ?? '' + // Why: a dim row has no typed text, so a typed-only read ends where the suggestion starts. + if (!(typedOnly ? typed : raw).trim() && !hasTypedContinuationAfter[index]) { break } - const typed = context.typedRowsBelow[index] ?? '' continuation.push({ - text: typed.trim() ? typed : raw, + text: typedOnly || typed.trim() ? typed : raw, wrapped: context.rowsBelowWrapped?.[index] ?? false }) } @@ -101,15 +108,16 @@ function isStockPlaceholder( } function detectTerminalComposer( - context: TerminalCursorContext | null | undefined + context: TerminalCursorContext | null | undefined, + typedOnly = false ): TerminalComposerMatch | null { if (!context || context.cursorHidden || context.rows.length === 0) { return null } const cursorIndex = context.rows.length - 1 const codexFooterIndex = findCodexFooterIndex(context) - let afterCursor = context.afterCursor || context.rawAfterCursor - let continuationRows = composerContinuationRows(context, afterCursor, codexFooterIndex) + let afterCursor = typedOnly ? context.afterCursor : context.afterCursor || context.rawAfterCursor + let continuationRows = composerContinuationRows(context, afterCursor, codexFooterIndex, typedOnly) const placeholder = isStockPlaceholder(afterCursor, continuationRows) if (placeholder) { afterCursor = '' @@ -179,9 +187,10 @@ function detectTerminalComposer( } export function detectTerminalComposerDraft( - context: TerminalCursorContext | null | undefined + context: TerminalCursorContext | null | undefined, + options: TerminalComposerDraftOptions = {} ): TerminalComposerDraft | null { - const match = detectTerminalComposer(context) + const match = detectTerminalComposer(context, options.typedOnly === true) if (!match || match.placeholder) { return null } From 4a3dd96b64868407920db7d25a8c95f9cacfd1c9 Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:22:34 +0200 Subject: [PATCH 2/7] fix(agent-prompt): keep recalled drafts and Enter-only retries safe under repaint and harmless output A landed paste still painted counted as a late repaint for 10 s after its turn start, so a prompt someone recalled or retyped into the emptied composer in that window was pasted over. Read the composer after the turn start and after each later output chunk, once parsed; the first read that no longer shows the landed paste ends the exemption, and the same words after that are refused as foreign. The watch stops then, or after 10 s. An Enter-only retry refused whenever any PTY output arrived during the pre-write check, even a title that left the parked prompt in place. Wait for the screen model to parse that output, then re-read the composer synchronously right before the Enter; refuse only if it no longer reads as the parked paste or more output arrived after the parse. Co-Authored-By: Claude Opus 5.5 --- .../runtime/agent-prompt-composer-ledger.ts | 75 +++++++++++++++--- .../agent-prompt-composer-residue.test.ts | 77 +++++++++++++++++-- .../runtime/agent-prompt-composer-residue.ts | 25 ++++-- ...runtime-create-agent-prompt-render-gate.ts | 20 +++++ ...rca-runtime-write-terminal-agent-prompt.ts | 14 +++- 5 files changed, 185 insertions(+), 26 deletions(-) diff --git a/src/main/runtime/agent-prompt-composer-ledger.ts b/src/main/runtime/agent-prompt-composer-ledger.ts index 4fa50586644a..58372ba616ab 100644 --- a/src/main/runtime/agent-prompt-composer-ledger.ts +++ b/src/main/runtime/agent-prompt-composer-ledger.ts @@ -1,18 +1,36 @@ -import type { AgentPromptOwnPaste } from './agent-prompt-composer-residue' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' +import { + composerShowsAgentPromptPaste, + type AgentPromptOwnPaste +} from './agent-prompt-composer-residue' // Why: right after Orca's own Enter the emulator can still paint the prompt it just submitted. const AGENT_PROMPT_COMPOSER_SETTLE_MS = 1_000 // Why: a turn start proves the agent took the prompt, so its text still on screen is a late repaint; -// long after that, the same text is someone's recalled draft again. +// long after that, or once the composer was seen repainted, the same text is someone's draft again. const AGENT_PROMPT_LANDED_REPAINT_MS = 10_000 +/** Calls back with the pane's composer, asynchronously, each time its screen model has parsed + * the output received so far; the returned function stops the callbacks. */ +export type AgentPromptComposerWatch = ( + ptyId: string, + onParsed: (context: TerminalCursorContext | null | undefined) => void +) => () => void + +type OwnPasteRecord = { + generation: number + payload: string + landedAt: number | null + repainted: boolean +} + /** Per pane: Orca's own last prompt paste and Enter, the evidence a composer verdict rests on. */ export class AgentPromptComposerLedger { private readonly submittedAtByPtyId = new Map() - private readonly lastPasteByPtyId = new Map< - string, - { generation: number; payload: string; landedAt: number | null } - >() + private readonly lastPasteByPtyId = new Map() + private readonly repaintWatchByPtyId = new Map void }>() + + constructor(private readonly watchComposer: AgentPromptComposerWatch) {} markSubmitted(ptyId: string, generation: number): void { const now = Date.now() @@ -44,15 +62,43 @@ export class AgentPromptComposerLedger { for (const id of this.lastPasteByPtyId.keys()) { if (!isLivePty(id)) { this.lastPasteByPtyId.delete(id) + this.stopRepaintWatch(id) } } - this.lastPasteByPtyId.set(ptyId, { generation, payload, landedAt: null }) + this.stopRepaintWatch(ptyId) + this.lastPasteByPtyId.set(ptyId, { generation, payload, landedAt: null, repainted: false }) } markLanded(ptyId: string, generation: number, payload: string): void { const paste = this.lastPasteByPtyId.get(ptyId) - if (paste?.generation === generation && paste.payload === payload) { - paste.landedAt = Date.now() + if (paste?.generation !== generation || paste.payload !== payload) { + return + } + paste.landedAt = Date.now() + this.stopRepaintWatch(ptyId) + // Why watch: a late repaint leaves the landed text untouched, while a recalled or retyped + // draft first needs the composer to read otherwise. Once it has, the exemption ends. + const watch = { stop: (): void => {} } + this.repaintWatchByPtyId.set(ptyId, watch) + const unsubscribe = this.watchComposer(ptyId, (context) => { + if (this.repaintWatchByPtyId.get(ptyId) !== watch) { + return + } + if (!composerShowsAgentPromptPaste(context, payload)) { + paste.repainted = true + this.stopRepaintWatch(ptyId) + } + }) + // Why: past the window the verdict no longer needs the watch, even on a pane gone quiet. + const expiry = setTimeout(() => { + if (this.repaintWatchByPtyId.get(ptyId) === watch) { + this.stopRepaintWatch(ptyId) + } + }, AGENT_PROMPT_LANDED_REPAINT_MS) + expiry.unref?.() + watch.stop = () => { + clearTimeout(expiry) + unsubscribe() } } @@ -64,8 +110,17 @@ export class AgentPromptComposerLedger { if (paste.landedAt === null) { return { payload: paste.payload, landed: false } } - return Date.now() - paste.landedAt < AGENT_PROMPT_LANDED_REPAINT_MS + return !paste.repainted && isInLandedRepaintWindow(paste) ? { payload: paste.payload, landed: true } : null } + + private stopRepaintWatch(ptyId: string): void { + this.repaintWatchByPtyId.get(ptyId)?.stop() + this.repaintWatchByPtyId.delete(ptyId) + } +} + +function isInLandedRepaintWindow(paste: OwnPasteRecord): boolean { + return paste.landedAt !== null && Date.now() - paste.landedAt < AGENT_PROMPT_LANDED_REPAINT_MS } diff --git a/src/main/runtime/agent-prompt-composer-residue.test.ts b/src/main/runtime/agent-prompt-composer-residue.test.ts index 9c2c323af6cc..c84445d30b73 100644 --- a/src/main/runtime/agent-prompt-composer-residue.test.ts +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -315,7 +315,7 @@ describe('agent prompt composer residue (#15976)', () => { } ) - it('refuses a parked prompt when output arrives during the pre-write check', async () => { + it('submits a parked prompt when only a title arrives during the pre-write check', async () => { vi.useFakeTimers() let enters = 0 const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( @@ -347,12 +347,10 @@ describe('agent prompt composer residue (#15976)', () => { await vi.runAllTimersAsync() const [result] = await settled + // Why: harmless output leaves the parked prompt in place, so the retry's Enter still belongs to it. expect({ result, writes: writes.slice(2) }).toMatchObject({ - result: { - status: 'rejected', - reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) - }, - writes: [] + result: { status: 'fulfilled', value: { accepted: true, bytesWritten: 1 } }, + writes: ['\r'] }) }) @@ -544,4 +542,71 @@ describe('agent prompt composer residue (#15976)', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(2) } ) + + it('still reads its landed prompt as a late repaint while only unrelated output arrives', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('first task')) { + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // Output that leaves the stale composer paint as it was. + setTimeout(() => runtime.onPtyData('pty-prompt', WORKING_TITLE, Date.now()), 300) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + const next = runtime.sendTerminalAgentPrompt(handle, 'second task', { inputKind: 'driving' }) + await vi.runAllTimersAsync() + + await expect(first).resolves.toMatchObject({ accepted: true }) + await expect(next).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data.includes('second task'))).toHaveLength(1) + }) + + it.each([ + ['a different', 'second task'], + ['an identical', 'first task'] + ])( + 'refuses %s next prompt onto its landed prompt recalled after the composer emptied', + async (_label, nextPrompt) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('first task')) { + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // The agent empties its composer as the turn starts. + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const startedAt = Date.now() + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + await vi.advanceTimersByTimeAsync(2_000) + await expect(first).resolves.toMatchObject({ accepted: true }) + // Someone recalls the prompt into the emptied composer, still inside the late-repaint window. + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + const writesBefore = writes.length + const next = runtime.sendTerminalAgentPrompt(handle, nextPrompt, { inputKind: 'driving' }) + const settled = Promise.allSettled([next]) + await vi.advanceTimersByTimeAsync(2_000) + const [result] = await settled + + expect(Date.now() - startedAt).toBeLessThan(10_000) + expect({ result, writes: writes.slice(writesBefore) }).toMatchObject({ + result: { + status: 'rejected', + reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) + }, + writes: [] + }) + } + ) }) diff --git a/src/main/runtime/agent-prompt-composer-residue.ts b/src/main/runtime/agent-prompt-composer-residue.ts index 5d26caf499a0..26bf8d57509c 100644 --- a/src/main/runtime/agent-prompt-composer-residue.ts +++ b/src/main/runtime/agent-prompt-composer-residue.ts @@ -30,12 +30,7 @@ export function classifyAgentPromptComposerResidue( pastePayload: string, ownPaste?: AgentPromptOwnPaste | null ): AgentPromptComposerResidue { - if (!context) { - return 'none' - } - // Why typed text only: a dim suggestion is not input; typing replaces it. - const draft = detectTerminalComposerDraft(context, { typedOnly: true }) - const residue = normalizeComposerText(draft?.text ?? '') + const residue = readTypedComposerText(context) if (!residue) { return 'none' } @@ -50,6 +45,24 @@ export function classifyAgentPromptComposerResidue( return ownPaste.payload === pastePayload ? 'same-prompt' : 'foreign' } +/** Whether the composer still shows this paste's words; false once it is emptied or holds others. */ +export function composerShowsAgentPromptPaste( + context: TerminalCursorContext | null | undefined, + payload: string +): boolean { + const residue = readTypedComposerText(context) + return residue !== '' && residue === normalizeComposerText(stripPasteFrame(payload)) +} + +function readTypedComposerText(context: TerminalCursorContext | null | undefined): string { + if (!context) { + return '' + } + // Why typed text only: a dim suggestion is not input; typing replaces it. + const draft = detectTerminalComposerDraft(context, { typedOnly: true }) + return normalizeComposerText(draft?.text ?? '') +} + function stripPasteFrame(payload: string): string { return payload .replaceAll(AGENT_PROMPT_BRACKETED_PASTE_START, '') diff --git a/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts b/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts index 2d537606aa49..f22b19e54509 100644 --- a/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts +++ b/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts @@ -6,6 +6,7 @@ import { CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS } from './orca-runtime-core' import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition } from '../../shared/runtime-types' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' export class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRuntimeWithWriteTerminalAgentPrompt { protected createAgentPromptRenderGate( @@ -128,6 +129,25 @@ export class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRuntimeWithW } } + /** The composer once the screen model has parsed what arrived so far, then after each later + * output chunk, until stopped. Always called back asynchronously. */ + protected watchAgentPromptComposer( + ptyId: string, + onParsed: (context: TerminalCursorContext | null | undefined) => void + ): () => void { + const readParsed = (): void => { + // Why judgeable only: a hydrating or provider-restored screen proves nothing either way. + const state = this.getJudgeableHeadlessTerminal(ptyId) + void state?.writeChain.then(() => { + if (this.getJudgeableHeadlessTerminal(ptyId) === state) { + onParsed(state.emulator.getCursorLineContext()) + } + }) + } + readParsed() + return this.subscribeToTerminalData(ptyId, readParsed) + } + waitForTerminal( handle: string, options?: { diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index b8bde1954085..604e85022392 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -30,9 +30,11 @@ import { import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { - private readonly agentPromptComposerLedger = new AgentPromptComposerLedger() + private readonly agentPromptComposerLedger = new AgentPromptComposerLedger((ptyId, onParsed) => + this.watchAgentPromptComposer(ptyId, onParsed) + ) - private getJudgeableHeadlessTerminal(ptyId: string) { + protected getJudgeableHeadlessTerminal(ptyId: string) { const state = this.headlessTerminals.get(ptyId) // Why: a provider-restored suffix or a pending hydration is not the whole screen, and doubt // never blocks a write. @@ -153,6 +155,10 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) await options.beforeWrite?.(ptyId) + // Why: harmless output (a title) can arrive while beforeWrite awaits; judge the screen it left. + const parked = promptAlreadyParked + ? await this.readAgentPromptComposerResidue(ptyId, generation, pastePayload, options.signal) + : null assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) this.assertAgentPromptPermissionSafe( @@ -161,8 +167,8 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ) // Why: beforeWrite awaited, and Enter alone submits whatever the composer holds by now. if ( - promptAlreadyParked && - !this.isAgentPromptStillParked(ptyId, generation, pastePayload, composer.parsedThrough) + parked && + !this.isAgentPromptStillParked(ptyId, generation, pastePayload, parked.parsedThrough) ) { throw new Error(AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR) } From 89b89e6cd5d775f70d0f89f66a65baf366fd6923 Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:26:48 +0200 Subject: [PATCH 3/7] fix(agent-prompt): an unreadable composer frame proves nothing during the late-repaint watch Co-Authored-By: Claude Opus 5.5 --- .../runtime/agent-prompt-composer-ledger.ts | 7 +- .../agent-prompt-composer-residue.test.ts | 82 +++++++++++++++++++ .../runtime/agent-prompt-composer-residue.ts | 12 ++- src/shared/terminal-composer-draft.test.ts | 33 +++++++- src/shared/terminal-composer-draft.ts | 14 ++-- 5 files changed, 134 insertions(+), 14 deletions(-) diff --git a/src/main/runtime/agent-prompt-composer-ledger.ts b/src/main/runtime/agent-prompt-composer-ledger.ts index 58372ba616ab..65089daac647 100644 --- a/src/main/runtime/agent-prompt-composer-ledger.ts +++ b/src/main/runtime/agent-prompt-composer-ledger.ts @@ -1,6 +1,6 @@ import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' import { - composerShowsAgentPromptPaste, + composerNoLongerShowsAgentPromptPaste, type AgentPromptOwnPaste } from './agent-prompt-composer-residue' @@ -77,14 +77,15 @@ export class AgentPromptComposerLedger { paste.landedAt = Date.now() this.stopRepaintWatch(ptyId) // Why watch: a late repaint leaves the landed text untouched, while a recalled or retyped - // draft first needs the composer to read otherwise. Once it has, the exemption ends. + // draft first needs the composer to read otherwise. Once it has, the exemption ends; a read + // that cannot see the composer does not end it, because doubt never blocks a write. const watch = { stop: (): void => {} } this.repaintWatchByPtyId.set(ptyId, watch) const unsubscribe = this.watchComposer(ptyId, (context) => { if (this.repaintWatchByPtyId.get(ptyId) !== watch) { return } - if (!composerShowsAgentPromptPaste(context, payload)) { + if (composerNoLongerShowsAgentPromptPaste(context, payload)) { paste.repainted = true this.stopRepaintWatch(ptyId) } diff --git a/src/main/runtime/agent-prompt-composer-residue.test.ts b/src/main/runtime/agent-prompt-composer-residue.test.ts index c84445d30b73..880bd3b07671 100644 --- a/src/main/runtime/agent-prompt-composer-residue.test.ts +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -609,4 +609,86 @@ describe('agent prompt composer residue (#15976)', () => { }) } ) + + it.each([ + ['a different', 'second task'], + ['an identical', 'first task'] + ])( + 'refuses %s next prompt onto a recall after a composer that emptied after the turn start', + async (_label, nextPrompt) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('first task')) { + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // Only a read after a later output chunk can see this emptying. + setTimeout(() => runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()), 1_500) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + await vi.advanceTimersByTimeAsync(3_000) + await expect(first).resolves.toMatchObject({ accepted: true }) + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + const writesBefore = writes.length + const next = runtime.sendTerminalAgentPrompt(handle, nextPrompt, { inputKind: 'driving' }) + const settled = Promise.allSettled([next]) + await vi.advanceTimersByTimeAsync(2_000) + const [result] = await settled + + expect({ result, writes: writes.slice(writesBefore) }).toMatchObject({ + result: { + status: 'rejected', + reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) + }, + writes: [] + }) + } + ) + + it.each([ + ['a hidden-cursor frame split across chunks', '\x1b[?25l', '\x1b[?25h'], + ['a cursor-up status redraw split across chunks', '\x1b7\x1b[1;1H', '✻ Working\x1b8'], + [ + 'a status line redrawn below the composer split across chunks', + '\x1b7\x1b[6;1H', + '✻ Working\x1b8' + ] + ])( + 'still pastes a different next prompt over a late repaint after %s', + async (_label, chunkOne, chunkTwo) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + const pasted = ['first task', 'second task'].find((text) => data.includes(text)) + if (pasted) { + runtime.onPtyData('pty-prompt', composerFrame(pasted), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // A read between these two chunks cannot see the composer, which still shows the paste. + setTimeout(() => runtime.onPtyData('pty-prompt', chunkOne, Date.now()), 300) + setTimeout(() => runtime.onPtyData('pty-prompt', chunkTwo, Date.now()), 320) + setTimeout(() => runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()), 1_500) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + const next = runtime.sendTerminalAgentPrompt(handle, 'second task', { inputKind: 'driving' }) + const settled = Promise.allSettled([first, next]) + await vi.runAllTimersAsync() + const [firstResult, nextResult] = await settled + + expect({ + firstResult: firstResult.status, + nextReason: nextResult.status === 'rejected' ? String(nextResult.reason?.message) : null, + pastes: writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)).length + }).toEqual({ firstResult: 'fulfilled', nextReason: null, pastes: 2 }) + } + ) }) diff --git a/src/main/runtime/agent-prompt-composer-residue.ts b/src/main/runtime/agent-prompt-composer-residue.ts index 26bf8d57509c..74b5b6d38460 100644 --- a/src/main/runtime/agent-prompt-composer-residue.ts +++ b/src/main/runtime/agent-prompt-composer-residue.ts @@ -4,6 +4,7 @@ import { } from '../../shared/agent-prompt-injection' import { detectTerminalComposerDraft, + isTerminalComposerAtCursor, type TerminalCursorContext } from '../../shared/terminal-composer-draft' @@ -45,13 +46,16 @@ export function classifyAgentPromptComposerResidue( return ownPaste.payload === pastePayload ? 'same-prompt' : 'foreign' } -/** Whether the composer still shows this paste's words; false once it is emptied or holds others. */ -export function composerShowsAgentPromptPaste( +/** Whether the composer was seen no longer showing this paste: emptied, or holding other words. */ +export function composerNoLongerShowsAgentPromptPaste( context: TerminalCursorContext | null | undefined, payload: string ): boolean { - const residue = readTypedComposerText(context) - return residue !== '' && residue === normalizeComposerText(stripPasteFrame(payload)) + // Why: a read that cannot see the composer (hidden cursor, cursor elsewhere) proves nothing. + if (!isTerminalComposerAtCursor(context)) { + return false + } + return readTypedComposerText(context) !== normalizeComposerText(stripPasteFrame(payload)) } function readTypedComposerText(context: TerminalCursorContext | null | undefined): string { diff --git a/src/shared/terminal-composer-draft.test.ts b/src/shared/terminal-composer-draft.test.ts index b42afd8cb8fc..6e588967d5e3 100644 --- a/src/shared/terminal-composer-draft.test.ts +++ b/src/shared/terminal-composer-draft.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { detectTerminalComposerDraft, - hasTerminalComposerPlaceholder + hasTerminalComposerPlaceholder, + isTerminalComposerAtCursor } from './terminal-composer-draft' describe('detectTerminalComposerDraft', () => { @@ -417,4 +418,34 @@ describe('detectTerminalComposerDraft', () => { }) ).toMatchObject({ text: 'first line', endRow: 8 }) }) + + it('tells an empty composer at the cursor from a read that cannot see one', () => { + const empty = { + rows: ['────────', '❯ '], + typedRows: ['────────', '❯ '], + promptGlyphBoldRows: [false, false], + rowsBelow: ['────────'], + typedRowsBelow: ['────────'], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 3 + } + + expect(detectTerminalComposerDraft(empty)).toBeNull() + expect(isTerminalComposerAtCursor(empty)).toBe(true) + expect(isTerminalComposerAtCursor({ ...empty, cursorHidden: true })).toBe(false) + expect( + isTerminalComposerAtCursor({ + ...empty, + rows: [...empty.rows, '────────', ''], + typedRows: [...empty.typedRows, '────────', ''], + promptGlyphBoldRows: [false, false, false, false], + beforeCursor: '', + cursorViewportRow: 5 + }) + ).toBe(false) + expect(isTerminalComposerAtCursor(null)).toBe(false) + }) }) diff --git a/src/shared/terminal-composer-draft.ts b/src/shared/terminal-composer-draft.ts index 7db01f00eb2d..98a8745b6f03 100644 --- a/src/shared/terminal-composer-draft.ts +++ b/src/shared/terminal-composer-draft.ts @@ -165,11 +165,6 @@ function detectTerminalComposer( }) .join('') .trim() - if (!text) { - if (!placeholder) { - return null - } - } return { text, promptRow: context.cursorViewportRow - (cursorIndex - index), @@ -191,7 +186,7 @@ export function detectTerminalComposerDraft( options: TerminalComposerDraftOptions = {} ): TerminalComposerDraft | null { const match = detectTerminalComposer(context, options.typedOnly === true) - if (!match || match.placeholder) { + if (!match || match.placeholder || !match.text) { return null } return { @@ -203,6 +198,13 @@ export function detectTerminalComposerDraft( } } +/** Whether the cursor is in a composer Orca recognizes, empty or not; false for a hidden cursor. */ +export function isTerminalComposerAtCursor( + context: TerminalCursorContext | null | undefined +): boolean { + return detectTerminalComposer(context) !== null +} + export function hasTerminalComposerPlaceholder( context: TerminalCursorContext | null | undefined ): boolean { From 7e26cd1906a740e20a03f0054573328a77c3ffd5 Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:13:39 +0200 Subject: [PATCH 4/7] test(agent-prompt): the late-repaint watch reads typed composer text, not a dim suggestion Co-Authored-By: Claude Opus 5.5 --- .../agent-prompt-composer-residue.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/main/runtime/agent-prompt-composer-residue.test.ts b/src/main/runtime/agent-prompt-composer-residue.test.ts index 880bd3b07671..16b377bf5817 100644 --- a/src/main/runtime/agent-prompt-composer-residue.test.ts +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -691,4 +691,80 @@ describe('agent prompt composer residue (#15976)', () => { }).toEqual({ firstResult: 'fulfilled', nextReason: null, pastes: 2 }) } ) + + it.each([ + ['a different', 'second task'], + ['an identical', 'first task'] + ])( + 'refuses %s next prompt onto a recall after the composer emptied to a dim ghost of the same words', + async (_label, nextPrompt) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('first task')) { + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // Emptied, but showing the sent words as a dim suggestion: no typed text is left. + setTimeout( + () => runtime.onPtyData('pty-prompt', composerFrame('', 'first task'), Date.now()), + 1_500 + ) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + await vi.advanceTimersByTimeAsync(3_000) + await expect(first).resolves.toMatchObject({ accepted: true }) + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + const writesBefore = writes.length + const next = runtime.sendTerminalAgentPrompt(handle, nextPrompt, { inputKind: 'driving' }) + const settled = Promise.allSettled([next]) + await vi.advanceTimersByTimeAsync(2_000) + const [result] = await settled + + expect({ result, writes: writes.slice(writesBefore) }).toMatchObject({ + result: { + status: 'rejected', + reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) + }, + writes: [] + }) + } + ) + + it('still pastes a different next prompt over a late repaint that shows a dim completion after the landed text', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + const pasted = ['first task', 'second task'].find((text) => data.includes(text)) + if (pasted) { + runtime.onPtyData('pty-prompt', composerFrame(pasted), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // The stale paste is still painted, now with a dim completion that is not input. + setTimeout( + () => runtime.onPtyData('pty-prompt', composerFrame('first task', ' now'), Date.now()), + 300 + ) + setTimeout(() => runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()), 1_500) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + const next = runtime.sendTerminalAgentPrompt(handle, 'second task', { inputKind: 'driving' }) + const settled = Promise.allSettled([first, next]) + await vi.runAllTimersAsync() + const [firstResult, nextResult] = await settled + + expect({ + firstResult: firstResult.status, + nextReason: nextResult.status === 'rejected' ? String(nextResult.reason?.message) : null, + pastes: writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)).length + }).toEqual({ firstResult: 'fulfilled', nextReason: null, pastes: 2 }) + }) }) From e8eae7b08d9f5255f3bad56f83f74c52f7228909 Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:52:38 +0200 Subject: [PATCH 5/7] test(agent-prompt): the late-repaint watch normalizes whitespace on both sides and ends on other words, not only an empty composer Co-Authored-By: Claude Opus 5.5 --- .../agent-prompt-composer-residue.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/main/runtime/agent-prompt-composer-residue.test.ts b/src/main/runtime/agent-prompt-composer-residue.test.ts index 16b377bf5817..ab26055b55f4 100644 --- a/src/main/runtime/agent-prompt-composer-residue.test.ts +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -767,4 +767,90 @@ describe('agent prompt composer residue (#15976)', () => { pastes: writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)).length }).toEqual({ firstResult: 'fulfilled', nextReason: null, pastes: 2 }) }) + + it.each([ + [ + 'a multi-line', + 'if ready:\ndeploy()', + `\x1b[2J\x1b[HClaude Code\r\n\r\n${FRAME}\r\n❯ if ready:\r\n deploy()\r\n${FRAME}\r\n` + + '\x1b[5;11H' + ], + ['a double-spaced', 'first task', composerFrame('first task')] + ])( + 'still pastes a different next prompt over a late repaint of %s landed prompt', + async (_label, firstPrompt, firstFrame) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('second task')) { + runtime.onPtyData('pty-prompt', composerFrame('second task'), Date.now()) + } else if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + // The composer shows the landed prompt with its whitespace collapsed. + runtime.onPtyData('pty-prompt', firstFrame, Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // The turn starts at once, but the emptied composer is painted only later. + setTimeout(() => runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()), 1_500) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, firstPrompt, { inputKind: 'driving' }) + const next = runtime.sendTerminalAgentPrompt(handle, 'second task', { inputKind: 'driving' }) + const settled = Promise.allSettled([first, next]) + await vi.runAllTimersAsync() + const [firstResult, nextResult] = await settled + + expect({ + firstResult: firstResult.status, + nextReason: nextResult.status === 'rejected' ? String(nextResult.reason?.message) : null, + pastes: writes.filter((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)).length + }).toEqual({ firstResult: 'fulfilled', nextReason: null, pastes: 2 }) + } + ) + + it.each([ + ['a different', 'second task'], + ['an identical', 'first task'] + ])( + 'refuses %s next prompt onto a recall after the composer showed other words', + async (_label, nextPrompt) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes('first task')) { + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) + // The composer goes from the landed paste straight to other words, never read empty. + setTimeout( + () => runtime.onPtyData('pty-prompt', composerFrame('fix the'), Date.now()), + 1_500 + ) + } + } + ) + runtime.onPtyData('pty-prompt', composerFrame(''), Date.now()) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first task', { inputKind: 'driving' }) + await vi.advanceTimersByTimeAsync(3_000) + await expect(first).resolves.toMatchObject({ accepted: true }) + // A history recall replaces those words with the landed prompt. + runtime.onPtyData('pty-prompt', composerFrame('first task'), Date.now()) + const writesBefore = writes.length + const next = runtime.sendTerminalAgentPrompt(handle, nextPrompt, { inputKind: 'driving' }) + const settled = Promise.allSettled([next]) + await vi.advanceTimersByTimeAsync(2_000) + const [result] = await settled + + expect({ result, writes: writes.slice(writesBefore) }).toMatchObject({ + result: { + status: 'rejected', + reason: expect.objectContaining({ message: 'agent_prompt_composer_not_empty' }) + }, + writes: [] + }) + } + ) }) From 97af768a322cb56821250226bfdf01fb64cb25fa Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:15:05 +0200 Subject: [PATCH 6/7] test(agent-prompt): describe the late-repaint frames' spacing correctly Co-Authored-By: Claude Opus 5.5 --- src/main/runtime/agent-prompt-composer-residue.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/agent-prompt-composer-residue.test.ts b/src/main/runtime/agent-prompt-composer-residue.test.ts index ab26055b55f4..6218a7c0e35f 100644 --- a/src/main/runtime/agent-prompt-composer-residue.test.ts +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -785,7 +785,7 @@ describe('agent prompt composer residue (#15976)', () => { if (data.includes('second task')) { runtime.onPtyData('pty-prompt', composerFrame('second task'), Date.now()) } else if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { - // The composer shows the landed prompt with its whitespace collapsed. + // The composer still shows the landed prompt, re-wrapped or with its own spacing. runtime.onPtyData('pty-prompt', firstFrame, Date.now()) } else if (data === '\r') { runtime.onPtyData('pty-prompt', `\x1b]0;Codex idle\x07${WORKING_TITLE}`, Date.now()) From 50982cf445ff0ee78ce84b1a57b13b39dab2c8ef Mon Sep 17 00:00:00 2001 From: brynnclaw <261708852+brynnclaw@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:39:16 +0200 Subject: [PATCH 7/7] refactor(agent-prompt): read the composer through the paste ledger to stay within the writer's line limit Main's composer-ready launch path (#24257) added a resubmit method to the prompt writer, which put the rebased file at 330 counted lines against the repo's 300-line limit. The two composer reads (the residue read and the synchronous parked re-read) move unchanged into the type-checked ledger, which reads the pane's screen through a narrow host. No behavior change. --- .../runtime/agent-prompt-composer-ledger.ts | 75 ++++++++++++++- ...rca-runtime-write-terminal-agent-prompt.ts | 92 +++++-------------- 2 files changed, 95 insertions(+), 72 deletions(-) diff --git a/src/main/runtime/agent-prompt-composer-ledger.ts b/src/main/runtime/agent-prompt-composer-ledger.ts index 65089daac647..fd4da6bae0ea 100644 --- a/src/main/runtime/agent-prompt-composer-ledger.ts +++ b/src/main/runtime/agent-prompt-composer-ledger.ts @@ -1,8 +1,11 @@ import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' import { + classifyAgentPromptComposerResidue, composerNoLongerShowsAgentPromptPaste, + type AgentPromptComposerResidue, type AgentPromptOwnPaste } from './agent-prompt-composer-residue' +import { waitForAgentPromptDelay, waitForAgentPromptPromise } from './orca-runtime-core' // Why: right after Orca's own Enter the emulator can still paint the prompt it just submitted. const AGENT_PROMPT_COMPOSER_SETTLE_MS = 1_000 @@ -17,6 +20,20 @@ export type AgentPromptComposerWatch = ( onParsed: (context: TerminalCursorContext | null | undefined) => void ) => () => void +type AgentPromptComposerScreen = { + writeChain: Promise + emulator: { getCursorLineContext: () => TerminalCursorContext | null | undefined } +} + +/** The runtime state the ledger reads a pane's composer from. */ +export type AgentPromptComposerHost = { + watchComposer: AgentPromptComposerWatch + getScreen: (ptyId: string) => AgentPromptComposerScreen | undefined + /** The screen only when it is the whole screen: no provider-restored suffix or hydration. */ + getJudgeableScreen: (ptyId: string) => AgentPromptComposerScreen | null + getGeneration: (ptyId: string) => number +} + type OwnPasteRecord = { generation: number payload: string @@ -30,7 +47,61 @@ export class AgentPromptComposerLedger { private readonly lastPasteByPtyId = new Map() private readonly repaintWatchByPtyId = new Map void }>() - constructor(private readonly watchComposer: AgentPromptComposerWatch) {} + constructor(private readonly host: AgentPromptComposerHost) {} + + async readResidue( + ptyId: string, + generation: number, + pastePayload: string, + signal?: AbortSignal + ): Promise<{ residue: AgentPromptComposerResidue; parsedThrough: Promise | null }> { + const state = this.host.getJudgeableScreen(ptyId) + if (!state) { + return { residue: 'none', parsedThrough: null } + } + const settleMs = this.settleMsLeft(ptyId, generation) + if (settleMs > 0) { + await waitForAgentPromptDelay(settleMs, signal) + } + const parsedThrough = state.writeChain + await waitForAgentPromptPromise(parsedThrough, signal) + if (this.host.getScreen(ptyId) !== state || this.host.getGeneration(ptyId) !== generation) { + return { residue: 'none', parsedThrough: null } + } + return { + residue: classifyAgentPromptComposerResidue( + state.emulator.getCursorLineContext(), + pastePayload, + this.getOwnPaste(ptyId, generation) + ), + parsedThrough + } + } + + /** Synchronous re-read: is the composer still exactly the parked prompt judged before the awaits? */ + isStillParked( + ptyId: string, + generation: number, + pastePayload: string, + parsedThrough: Promise | null + ): boolean { + const state = this.host.getJudgeableScreen(ptyId) + // Why: a newer write-chain link means PTY output arrived since the read, parsed or not yet. + if ( + !state || + state.writeChain !== parsedThrough || + this.host.getGeneration(ptyId) !== generation + ) { + return false + } + return ( + classifyAgentPromptComposerResidue( + state.emulator.getCursorLineContext(), + pastePayload, + this.getOwnPaste(ptyId, generation) + ) === 'same-prompt' + ) + } markSubmitted(ptyId: string, generation: number): void { const now = Date.now() @@ -81,7 +152,7 @@ export class AgentPromptComposerLedger { // that cannot see the composer does not end it, because doubt never blocks a write. const watch = { stop: (): void => {} } this.repaintWatchByPtyId.set(ptyId, watch) - const unsubscribe = this.watchComposer(ptyId, (context) => { + const unsubscribe = this.host.watchComposer(ptyId, (context) => { if (this.repaintWatchByPtyId.get(ptyId) !== watch) { return } diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 604e85022392..036864e8e3c5 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -7,11 +7,7 @@ import { waitForAgentPromptDelay, waitForAgentPromptPromise } from './orca-runtime-core' -import { - AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR, - classifyAgentPromptComposerResidue, - type AgentPromptComposerResidue -} from './agent-prompt-composer-residue' +import { AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR } from './agent-prompt-composer-residue' import { AgentPromptComposerLedger } from './agent-prompt-composer-ledger' import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS, @@ -30,9 +26,12 @@ import { import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { - private readonly agentPromptComposerLedger = new AgentPromptComposerLedger((ptyId, onParsed) => - this.watchAgentPromptComposer(ptyId, onParsed) - ) + private readonly agentPromptComposerLedger = new AgentPromptComposerLedger({ + watchComposer: (ptyId, onParsed) => this.watchAgentPromptComposer(ptyId, onParsed), + getScreen: (ptyId) => this.headlessTerminals.get(ptyId), + getJudgeableScreen: (ptyId) => this.getJudgeableHeadlessTerminal(ptyId), + getGeneration: (ptyId) => this.getPtyLifecycleGeneration(ptyId) + }) protected getJudgeableHeadlessTerminal(ptyId: string) { const state = this.headlessTerminals.get(ptyId) @@ -48,63 +47,6 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso return state } - private async readAgentPromptComposerResidue( - ptyId: string, - generation: number, - pastePayload: string, - signal?: AbortSignal - ): Promise<{ residue: AgentPromptComposerResidue; parsedThrough: Promise | null }> { - const state = this.getJudgeableHeadlessTerminal(ptyId) - if (!state) { - return { residue: 'none', parsedThrough: null } - } - const settleMs = this.agentPromptComposerLedger.settleMsLeft(ptyId, generation) - if (settleMs > 0) { - await waitForAgentPromptDelay(settleMs, signal) - } - const parsedThrough = state.writeChain - await waitForAgentPromptPromise(parsedThrough, signal) - if ( - this.headlessTerminals.get(ptyId) !== state || - this.getPtyLifecycleGeneration(ptyId) !== generation - ) { - return { residue: 'none', parsedThrough: null } - } - return { - residue: classifyAgentPromptComposerResidue( - state.emulator.getCursorLineContext(), - pastePayload, - this.agentPromptComposerLedger.getOwnPaste(ptyId, generation) - ), - parsedThrough - } - } - - /** Synchronous re-read: is the composer still exactly the parked prompt judged before the awaits? */ - private isAgentPromptStillParked( - ptyId: string, - generation: number, - pastePayload: string, - parsedThrough: Promise | null - ): boolean { - const state = this.getJudgeableHeadlessTerminal(ptyId) - // Why: a newer write-chain link means PTY output arrived since the read, parsed or not yet. - if ( - !state || - state.writeChain !== parsedThrough || - this.getPtyLifecycleGeneration(ptyId) !== generation - ) { - return false - } - return ( - classifyAgentPromptComposerResidue( - state.emulator.getCursorLineContext(), - pastePayload, - this.agentPromptComposerLedger.getOwnPaste(ptyId, generation) - ) === 'same-prompt' - ) - } - protected async writeTerminalAgentPrompt( handle: string, ptyId: string, @@ -116,7 +58,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso this.assertAgentPromptGeneration(ptyId, generation) // Why (#15976): a paste concatenates onto whatever the composer holds, so a retry after an // unobserved submit corrupted both prompts. Refuse foreign text; re-submit our own parked prompt. - const composer = await this.readAgentPromptComposerResidue( + const composer = await this.agentPromptComposerLedger.readResidue( ptyId, generation, pastePayload, @@ -145,8 +87,8 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // its 8 s cap, where the desktop's own paste submitted in about 2 s. const renderGate = promptAlreadyParked || options.composerReady - ? null - : this.createAgentPromptRenderGate(ptyId, pasteIngestMs) + ? null + : this.createAgentPromptRenderGate(ptyId, pasteIngestMs) const waitTextCache: AgentPromptWaitTextCache = {} const preSubmitBaseline = submitWithPaste ? this.getAgentPromptActivity(handle, ptyId, waitTextCache) @@ -157,7 +99,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso await options.beforeWrite?.(ptyId) // Why: harmless output (a title) can arrive while beforeWrite awaits; judge the screen it left. const parked = promptAlreadyParked - ? await this.readAgentPromptComposerResidue(ptyId, generation, pastePayload, options.signal) + ? await this.agentPromptComposerLedger.readResidue( + ptyId, + generation, + pastePayload, + options.signal + ) : null assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -168,7 +115,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // Why: beforeWrite awaited, and Enter alone submits whatever the composer holds by now. if ( parked && - !this.isAgentPromptStillParked(ptyId, generation, pastePayload, parked.parsedThrough) + !this.agentPromptComposerLedger.isStillParked( + ptyId, + generation, + pastePayload, + parked.parsedThrough + ) ) { throw new Error(AGENT_PROMPT_COMPOSER_NOT_EMPTY_ERROR) }