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..fd4da6bae0ea --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-ledger.ts @@ -0,0 +1,198 @@ +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 +// Why: a turn start proves the agent took the prompt, so its text still on screen is a late repaint; +// 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 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 + 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() + private readonly repaintWatchByPtyId = new Map void }>() + + 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() + // 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.stopRepaintWatch(id) + } + } + 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) { + 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; 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.host.watchComposer(ptyId, (context) => { + if (this.repaintWatchByPtyId.get(ptyId) !== watch) { + return + } + if (composerNoLongerShowsAgentPromptPaste(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() + } + } + + 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 !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 new file mode 100644 index 000000000000..6218a7c0e35f --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-residue.test.ts @@ -0,0 +1,856 @@ +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('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( + (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 + + // 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: 'fulfilled', value: { accepted: true, bytesWritten: 1 } }, + writes: ['\r'] + }) + }) + + 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) + } + ) + + 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: [] + }) + } + ) + + 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 }) + } + ) + + 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 }) + }) + + 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 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()) + // 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: [] + }) + } + ) +}) 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..74b5b6d38460 --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-residue.ts @@ -0,0 +1,79 @@ +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_BRACKETED_PASTE_START +} from '../../shared/agent-prompt-injection' +import { + detectTerminalComposerDraft, + isTerminalComposerAtCursor, + 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 { + const residue = readTypedComposerText(context) + 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' +} + +/** 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 { + // 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 { + 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, '') + .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-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 4f4839678ace..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,6 +7,8 @@ import { waitForAgentPromptDelay, waitForAgentPromptPromise } from './orca-runtime-core' +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, AGENT_PROMPT_SUBMIT, @@ -24,13 +26,48 @@ import { import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { + 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) + // 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 + } + 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.agentPromptComposerLedger.readResidue( + 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,16 +77,18 @@ 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 - ? null - : this.createAgentPromptRenderGate(ptyId, pasteIngestMs) + const renderGate = + promptAlreadyParked || options.composerReady + ? null + : this.createAgentPromptRenderGate(ptyId, pasteIngestMs) const waitTextCache: AgentPromptWaitTextCache = {} const preSubmitBaseline = submitWithPaste ? this.getAgentPromptActivity(handle, ptyId, waitTextCache) @@ -58,19 +97,49 @@ 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.agentPromptComposerLedger.readResidue( + ptyId, + generation, + pastePayload, + options.signal + ) + : null assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) this.assertAgentPromptPermissionSafe( permissionBaseline, this.getAgentPromptActivity(handle, ptyId) ) + // Why: beforeWrite awaited, and Enter alone submits whatever the composer holds by now. + if ( + parked && + !this.agentPromptComposerLedger.isStillParked( + ptyId, + generation, + pastePayload, + parked.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 +186,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 +201,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 +229,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 +237,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 +264,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 +275,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..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', () => { @@ -146,6 +147,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({ @@ -397,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 e33fe0cd8434..98a8745b6f03 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 = '' @@ -157,11 +165,6 @@ function detectTerminalComposer( }) .join('') .trim() - if (!text) { - if (!placeholder) { - return null - } - } return { text, promptRow: context.cursorViewportRow - (cursorIndex - index), @@ -179,10 +182,11 @@ function detectTerminalComposer( } export function detectTerminalComposerDraft( - context: TerminalCursorContext | null | undefined + context: TerminalCursorContext | null | undefined, + options: TerminalComposerDraftOptions = {} ): TerminalComposerDraft | null { - const match = detectTerminalComposer(context) - if (!match || match.placeholder) { + const match = detectTerminalComposer(context, options.typedOnly === true) + if (!match || match.placeholder || !match.text) { return null } return { @@ -194,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 {