mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(terminal): preserve large agent prompt pastes
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user