Revert "fix(agents): prevent startup paste from submitting user input"

This reverts commit affbf333e2.
This commit is contained in:
OrcaWin
2026-07-30 21:32:10 -07:00
parent 123d1780b2
commit 40560cc9f3
9 changed files with 12 additions and 127 deletions
@@ -2029,6 +2029,9 @@ function TerminalPane(
setTerminalError(formatTerminalPasteExecutionError(execution.reason))
return
}
if (text) {
recordTerminalUserInputForLeaf(tabId, pane.leafId)
}
if (options?.recoverImagePasteWebglAtlas) {
scheduleImagePasteWebglAtlasRecovery()
}
@@ -2052,7 +2055,6 @@ function TerminalPane(
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
onPasteIntent: () => recordTerminalUserInputForLeaf(tabId, pane.leafId),
pasteText: (text, options) =>
executePanePasteText(pane, source, activeElementAtDispatch, text, options),
onTextPasteError: () =>
@@ -2200,7 +2202,6 @@ function TerminalPane(
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
onPasteIntent: () => recordTerminalUserInputForLeaf(tabId, pane.leafId),
pasteText: (text, options) =>
executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options),
onTextPasteError: () =>
@@ -3621,7 +3621,6 @@ describe('connectPanePty', () => {
it('records hibernation activity from the core user-input signal, not synthetic onData replies', async () => {
// Regression: xterm auto-replies share the onData stream with typing; recording them as input made the planner see "input after done" and never hibernate.
const { connectPanePty } = await import('./pty-connection')
const { readTerminalUserInputGeneration } = await import('./terminal-input-activity')
const transport = createMockTransport('pty-pane-2')
transportFactoryQueue.push(transport)
const manager = createManager(1)
@@ -3652,12 +3651,8 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O')
// Real user input fires the core signal and records activity.
const activePtyId = (transport.getPtyId as unknown as () => string | null)()
expect(activePtyId).toBeTypeOf('string')
const inputGeneration = readTerminalUserInputGeneration(activePtyId!)
;(userInputListener as unknown as () => void)()
expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1)
expect(readTerminalUserInputGeneration(activePtyId!)).toBe(inputGeneration + 1)
binding.dispose()
expect(userInputDispose).toHaveBeenCalled()
@@ -136,7 +136,6 @@ import {
} from '../../../../shared/terminal-color-scheme-protocol'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
import { markTerminalUserInputForPtyId } from './terminal-input-activity'
import {
hasPtySerializer,
registerPtySerializer,
@@ -3616,10 +3615,6 @@ export function connectPanePty(
const recordTerminalInputForHibernation = (): void => {
useAppStore.getState().recordTerminalInput(cacheKey)
}
const recordRealTerminalUserInput = (): void => {
markTerminalUserInputForPtyId(transport.getPtyId())
recordTerminalInputForHibernation()
}
// Why: onData mixes real user input with xterm's parser auto-replies (focus
// reports, DA/DSR/CPR responses). Recording those replies as activity makes
// the hibernation planner treat a pane hidden after its agent finished as
@@ -3628,11 +3623,11 @@ export function connectPanePty(
// solely as the fallback when the internal API is unavailable.
const userInputActivityDisposable = subscribeToTerminalUserInput(
pane.terminal,
recordRealTerminalUserInput
recordTerminalInputForHibernation
)
const recordTerminalInputForHibernationFallback = (): void => {
if (userInputActivityDisposable === null) {
recordRealTerminalUserInput()
recordTerminalInputForHibernation()
}
}
const markAcceptedTerminalInputSent = (): void => {
@@ -186,23 +186,6 @@ describe('terminal clipboard paste', () => {
expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled()
})
it('records paste intent before waiting for clipboard text', async () => {
const onPasteIntent = vi.fn()
const readClipboardText = vi.fn(async () => {
expect(onPasteIntent).toHaveBeenCalledOnce()
return 'one\r\ntwo\r\nthree\r\nfour\r\nfive'
})
await pasteTerminalClipboard({
readClipboardText,
saveClipboardImageAsTempFile: vi.fn(),
pasteText: vi.fn(),
onPasteIntent
})
expect(onPasteIntent).toHaveBeenCalledOnce()
})
it('reports text paste execution failures without probing for image fallback', async () => {
const pasteError = new Error('terminal disconnected')
const saveClipboardImageAsTempFile = vi.fn()
@@ -22,7 +22,6 @@ type PasteTerminalClipboardDeps = {
connectionId?: string | null
runtimeEnvironmentId?: string | null
forceBracketedMultilineTextPaste?: boolean
onPasteIntent?: () => void
onTextPasteError?: (error: unknown) => void
onImagePasteError?: (error: unknown) => void
}
@@ -47,11 +46,9 @@ export async function pasteTerminalClipboard({
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste = false,
onPasteIntent,
onTextPasteError,
onImagePasteError
}: PasteTerminalClipboardDeps): Promise<TerminalClipboardPasteResult> {
onPasteIntent?.()
let text = ''
try {
text = await readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES })
@@ -1,29 +1,11 @@
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
// Why: delayed automatic Enter must yield when real input reaches the same PTY.
const userInputGenerationByPtyId = new Map<string, number>()
export function markTerminalUserInputForPtyId(ptyId: string | null | undefined): void {
if (!ptyId) {
return
}
userInputGenerationByPtyId.set(ptyId, (userInputGenerationByPtyId.get(ptyId) ?? 0) + 1)
}
export function readTerminalUserInputGeneration(ptyId: string): number {
return userInputGenerationByPtyId.get(ptyId) ?? 0
}
export function recordTerminalUserInputForLeaf(tabId: string, leafId: string): void {
try {
const state = useAppStore.getState()
const layoutPtyId = state.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId]
const tabPtyIds = state.ptyIdsByTabId?.[tabId] ?? []
markTerminalUserInputForPtyId(layoutPtyId ?? (tabPtyIds.length === 1 ? tabPtyIds[0] : null))
// Why: hibernation must see all user-authorized terminal writes, including
// sends that bypass xterm.onData.
state.recordTerminalInput(makePaneKey(tabId, leafId))
useAppStore.getState().recordTerminalInput(makePaneKey(tabId, leafId))
} catch {
// Legacy/malformed layouts are ignored; hibernation remains conservative
// when it cannot match live PTYs to stable pane keys.
@@ -266,6 +266,9 @@ export function useTerminalPaneContextMenu({
onPasteError(formatTerminalPasteExecutionError(execution.reason))
return false
}
if (text) {
recordTerminalUserInputForLeaf(tabId, pane.leafId)
}
if (options?.recoverImagePasteWebglAtlas) {
scheduleImagePasteWebglAtlasRecovery()
}
@@ -320,7 +323,6 @@ export function useTerminalPaneContextMenu({
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
onPasteIntent: () => recordTerminalUserInputForLeaf(tabId, pane.leafId),
pasteText: (text, options) => executeMenuPasteText(pane, source, text, options),
onTextPasteError: () =>
onPasteError('Paste failed: clipboard text is too large for a safe terminal paste.'),
+1 -48
View File
@@ -12,7 +12,6 @@ import {
sendBracketedPasteToRunningAgent,
submitPromptToAgentPty
} from './agent-paste-draft'
import { markTerminalUserInputForPtyId } from '@/components/terminal-pane/terminal-input-activity'
const testState = vi.hoisted(() => ({
appState: {
@@ -55,7 +54,7 @@ vi.mock('@/runtime/runtime-terminal-stream', () => ({
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const SHOW_CURSOR = '\x1b[?25h'
const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything'
const ISSUE_URL = 'Synthetic linked work item context'
const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123'
const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~`
describe('pasteDraftWhenAgentReady', () => {
@@ -359,52 +358,6 @@ describe('pasteDraftWhenAgentReady', () => {
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
})
it('does not submit when a five-line Windows paste follows injected context', async () => {
const ptyWrites: string[] = []
testState.sendRuntimePtyInputVerified.mockImplementation(
async (_settings: unknown, _ptyId: string, data: string) => {
ptyWrites.push(data)
return true
}
)
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex',
submit: true,
forcePaste: true
})
await flushMicrotasks()
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`)
await flushMicrotasks()
expect(ptyWrites).toEqual([PASTED_ISSUE_URL])
markTerminalUserInputForPtyId('pty-1')
ptyWrites.push('\x1b[200~one\rtwo\rthree\rfour\rfive\x1b[201~')
await vi.advanceTimersByTimeAsync(50)
await expect(promise).resolves.toBe(false)
expect(ptyWrites).toEqual([PASTED_ISSUE_URL, '\x1b[200~one\rtwo\rthree\rfour\rfive\x1b[201~'])
})
it('does not inject launch context after user input takes over during readiness', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex',
submit: true,
forcePaste: true
})
await flushMicrotasks()
markTerminalUserInputForPtyId('pty-1')
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`)
await expect(promise).resolves.toBe(false)
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
})
it('does not submit when the verified paste write fails', async () => {
testState.sendRuntimePtyInputVerified.mockResolvedValueOnce(false)
+2 -25
View File
@@ -10,7 +10,6 @@ import {
BRACKETED_PASTE_START,
sanitizeTerminalPasteText
} from '@/components/terminal-pane/terminal-bracketed-paste'
import { readTerminalUserInputGeneration } from '@/components/terminal-pane/terminal-input-activity'
import { waitForAgentReady } from './agent-ready-wait'
import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner'
import { sendAgentDraftPasteContent } from './agent-draft-paste-content'
@@ -103,8 +102,6 @@ export async function pasteDraftWhenAgentReady(args: {
onTimeout?.()
return false
}
const submitUserInputGeneration =
submit === true ? readTerminalUserInputGeneration(ptyId) : undefined
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
@@ -126,8 +123,7 @@ export async function pasteDraftWhenAgentReady(args: {
settings,
ptyId,
content,
submit: submit === true,
submitUserInputGeneration
submit: submit === true
})
}
@@ -150,8 +146,6 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
const budget = timeoutMs ?? READINESS_TIMEOUT_MS
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const submitUserInputGeneration =
submit === true ? readTerminalUserInputGeneration(ptyId) : undefined
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
if (!ready) {
@@ -168,8 +162,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
settings,
ptyId,
content,
submit: submit === true,
submitUserInputGeneration
submit: submit === true
})
}
@@ -216,17 +209,9 @@ async function sendBracketedPasteToAgent(args: {
ptyId: string
content: string
submit: boolean
submitUserInputGeneration?: number
}): Promise<boolean> {
const { settings = useAppStore.getState().settings, ptyId, content, submit } = args
const submitUserInputGeneration =
submit === true
? (args.submitUserInputGeneration ?? readTerminalUserInputGeneration(ptyId))
: undefined
try {
if (didTerminalUserInputChange(ptyId, submitUserInputGeneration)) {
return false
}
const pasted = await sendAgentDraftPasteContent(settings, ptyId, content)
if (!pasted) {
return false
@@ -239,20 +224,12 @@ async function sendBracketedPasteToAgent(args: {
// Enter arrive in the same PTY write. Split the submit into the next turn so
// the TUI processes bracketed-paste termination before handling Enter.
await new Promise<void>((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS))
// Why: user input has a separate transport queue; never submit mixed content.
if (didTerminalUserInputChange(ptyId, submitUserInputGeneration)) {
return false
}
return await sendRuntimePtyInputVerified(settings, ptyId, '\r')
} catch {
return false
}
}
function didTerminalUserInputChange(ptyId: string, generation: number | undefined): boolean {
return generation !== undefined && readTerminalUserInputGeneration(ptyId) !== generation
}
/**
* Why: activation creates the tab synchronously but the PTY spawn is
* async. Poll the store until the primary PTY id appears or the budget