From f336e62b367144acfaacd58fb4c01f2c70d2ee78 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 31 Aug 2026 15:31:32 -0400 Subject: [PATCH] fix(terminal): preserve large agent prompt pastes --- .../agent-prompt-submission-runtime.test.ts | 14 ++--- src/main/runtime/orca-runtime.test.ts | 39 +++++++++--- src/main/runtime/orca-runtime.ts | 61 +++++++------------ 3 files changed, 61 insertions(+), 53 deletions(-) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 3b8fe67bea7..14ac4cf4fb9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -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 () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e4c39a32583..cf3fbdd9b2f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c8a9c7eb376..81c29dfc39e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2297,16 +2297,15 @@ async function waitForAgentPromptPromise(promise: Promise, 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 { return new Promise((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 (