fix(terminal): preserve large agent prompt pastes

This commit is contained in:
Jinwoo-H
2026-08-31 16:05:10 -04:00
parent dc5db4b01c
commit f336e62b36
3 changed files with 61 additions and 53 deletions
@@ -270,7 +270,7 @@ describe('agent prompt submission runtime', () => {
expect(writes).not.toContain('\r')
})
it('stops a chunked paste when permission appears between chunks', async () => {
it('does not submit an atomic paste after permission appears', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
let writeChecks = 0
@@ -284,12 +284,12 @@ describe('agent prompt submission runtime', () => {
})
await expect(submission).rejects.toThrow('agent_prompt_blocked')
expect(writes).toHaveLength(2)
expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).toHaveLength(1)
expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).not.toContain('\r')
})
it('stops a chunked paste after transient output-only permission', async () => {
it('does not submit an atomic paste after transient output-only permission', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData('pty-prompt', 'initial output\n', Date.now())
let writeChecks = 0
@@ -309,8 +309,8 @@ describe('agent prompt submission runtime', () => {
})
await expect(submission).rejects.toThrow('agent_prompt_blocked')
expect(writes).toHaveLength(2)
expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).toHaveLength(1)
expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).not.toContain('\r')
})
@@ -736,7 +736,7 @@ describe('agent prompt submission runtime', () => {
await expect(submission).rejects.toThrow('terminal_handle_stale')
expect(writes).toHaveLength(1)
expect(writes[0]).not.toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
})
it('does not send delayed Enter after cancellation during settlement', async () => {
+32 -7
View File
@@ -18074,7 +18074,7 @@ describe('OrcaRuntimeService', () => {
}
})
it('chunks large agent prompt paste frames before delayed submit', async () => {
it('writes large agent prompt paste frames atomically before delayed submit', async () => {
vi.useFakeTimers()
try {
const writes: string[] = []
@@ -18103,7 +18103,7 @@ describe('OrcaRuntimeService', () => {
Buffer.byteLength(`${buildAgentPromptPasteBytes(prompt)}\r`, 'utf8')
)
expect(writes.at(-1)).toBe('\r')
expect(pasteWrites.length).toBeGreaterThan(1)
expect(pasteWrites).toHaveLength(1)
expect(pasteWrites.join('')).toBe(buildAgentPromptPasteBytes(prompt))
expect(pasteWrites[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_START)
expect(pasteWrites.at(-1)).toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
@@ -18112,18 +18112,16 @@ describe('OrcaRuntimeService', () => {
}
})
it('closes an incomplete agent prompt paste when a later chunk write fails', async () => {
it('rejects an agent prompt when the atomic paste write fails', async () => {
vi.useFakeTimers()
try {
const writes: string[] = []
let writeCount = 0
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writeCount += 1
writes.push(data)
return writeCount !== 2
return false
},
kill: () => true,
getForegroundProcess: async () => null
@@ -18139,7 +18137,7 @@ describe('OrcaRuntimeService', () => {
await sendRejection
expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_START)
expect(writes.at(-1)).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
expect(writes).toHaveLength(1)
expect(writes).not.toContain('\r')
} finally {
vi.useRealTimers()
@@ -18171,6 +18169,33 @@ describe('OrcaRuntimeService', () => {
expect(writes).toEqual(['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'])
})
it('yields chunked terminal input through timers so the PTY can drain between writes', async () => {
const immediate = vi.spyOn(globalThis, 'setImmediate')
try {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three`
await runtime.sendTerminal(handle, { text, enter: true })
expect(writes.at(-1)).toBe('\r')
expect(writes.slice(0, -1).join('')).toBe(text)
expect(immediate).not.toHaveBeenCalled()
} finally {
immediate.mockRestore()
}
})
it('yields while validating accepted large terminal.send text before provider writes', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
+22 -39
View File
@@ -2297,16 +2297,15 @@ async function waitForAgentPromptPromise<T>(promise: Promise<T>, signal?: AbortS
})
}
// Why not setTimeout(0): it costs a full ~15.19 ms Windows timer tick per chunk (~0.95 s/MB)
// and never bought backpressure -- 16 KiB per tick paces ~1.07 MB/s, 11x above ConPTY's
// ~96 KB/s drain, so the in-flight buffer grew regardless. setImmediate keeps the only thing
// the yield actually did (let abort/permission/data callbacks run between chunks) at ~0.01 ms,
// and TERMINAL_INPUT_MAX_BYTES still bounds what can be in flight either way.
// A timer yield gives the PTY/ConPTY provider one event-loop turn to drain each chunk. A
// setImmediate callback can run before that drain and makes large pastes arrive as a burst;
// some terminals then retain only the tail even though every write reports accepted.
// The 16 MiB input ceiling bounds the total in-flight data, and this remains cross-platform.
// Why the global and not node:timers/promises: only the global is intercepted by fake timers,
// so a chunked paste stays observable on the test clock.
function yieldBetweenTerminalInputChunks(): Promise<void> {
return new Promise<void>((resolve) => {
setImmediate(resolve)
setTimeout(resolve, 0)
})
}
@@ -22004,40 +22003,24 @@ export class OrcaRuntimeService {
let wrotePasteBytes = false
let completedPaste = false
try {
const chunks = iterateTerminalInputChunks(pastePayload)
let chunk = chunks.next()
let firstChunk = true
while (!chunk.done) {
const nextChunk = chunks.next()
assertAgentPromptRequestActive(options.signal)
this.assertAgentPromptGeneration(ptyId, generation)
// Why: the first chunk was just admitted above; re-checking the lease there would only
// re-read what `assertAdmitted` established.
if (!firstChunk) {
agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted)
}
firstChunk = false
await options.beforeWrite?.(ptyId)
assertAgentPromptRequestActive(options.signal)
this.assertAgentPromptGeneration(ptyId, generation)
this.assertAgentPromptPermissionSafe(
permissionBaseline,
this.getAgentPromptActivity(handle, ptyId)
)
agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted)
if (nextChunk.done) {
renderGate?.arm()
}
const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
}
wrotePasteBytes = true
chunk = nextChunk
if (!chunk.done) {
await yieldBetweenTerminalInputChunks()
}
assertAgentPromptRequestActive(options.signal)
this.assertAgentPromptGeneration(ptyId, generation)
await options.beforeWrite?.(ptyId)
assertAgentPromptRequestActive(options.signal)
this.assertAgentPromptGeneration(ptyId, generation)
this.assertAgentPromptPermissionSafe(
permissionBaseline,
this.getAgentPromptActivity(handle, ptyId)
)
agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted)
// 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 wrote = this.ptyController?.write(ptyId, pastePayload) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
}
wrotePasteBytes = true
completedPaste = true
} catch (error) {
if (