mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
fix(orchestration): wait for Claude composer render (#14342)
This commit is contained in:
@@ -16394,6 +16394,145 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for Claude output to settle after its first render marker before one submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const writes: string[] = []
|
||||
let composerReady = false
|
||||
let prematureEnters = 0
|
||||
let submissions = 0
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', 'partial redraw without cursor', Date.now())
|
||||
}, 650)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', '\x1b[?2', Date.now())
|
||||
}, 750)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', '5h intermediate frame', Date.now())
|
||||
}, 751)
|
||||
setTimeout(() => {
|
||||
runtime.onPtyData('pty-bg', 'continued composer render', Date.now())
|
||||
}, 900)
|
||||
setTimeout(() => {
|
||||
composerReady = true
|
||||
runtime.onPtyData('pty-bg', 'final composer frame', Date.now())
|
||||
}, 1_000)
|
||||
}
|
||||
if (data === '\r') {
|
||||
if (composerReady) {
|
||||
submissions += 1
|
||||
} else {
|
||||
prematureEnters += 1
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
const assertAuthority = vi.fn()
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
|
||||
beforeWrite: assertAuthority
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1_748)
|
||||
expect(writes).not.toContain('\r')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await sendPromise
|
||||
expect(prematureEnters).toBe(0)
|
||||
expect(submissions).toBe(1)
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
expect(assertAuthority).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('submits a silent Claude composer once after the bounded render fallback', async () => {
|
||||
vi.useFakeTimers()
|
||||
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}`, {
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
|
||||
await vi.advanceTimersByTimeAsync(7_999)
|
||||
expect(writes).not.toContain('\r')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await sendPromise
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds a Claude render that never settles to one fallback submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const writes: string[] = []
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
setTimeout(() => runtime.onPtyData('pty-bg', '\x1b[?25h', Date.now()), 100)
|
||||
for (const delay of [1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 7_000]) {
|
||||
setTimeout(
|
||||
() => runtime.onPtyData('pty-bg', `render frame ${delay}`, Date.now()),
|
||||
delay
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
|
||||
await vi.advanceTimersByTimeAsync(7_999)
|
||||
expect(writes).not.toContain('\r')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await sendPromise
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('chunks large agent prompt paste frames before delayed submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -16408,7 +16547,9 @@ describe('OrcaRuntimeService', () => {
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail`
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
|
||||
@@ -16445,7 +16586,9 @@ describe('OrcaRuntimeService', () => {
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1)
|
||||
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
|
||||
|
||||
@@ -1831,6 +1831,10 @@ const BRACKETED_PASTE_BEGIN = '\x1b[200~'
|
||||
const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
|
||||
const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
|
||||
const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
|
||||
// Why: Claude emits show-cursor while rendering its composer; output must settle afterward.
|
||||
const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
|
||||
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
|
||||
// Why: the split already failed; the caller waits on this teardown only to learn whether the
|
||||
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
|
||||
@@ -17438,19 +17442,24 @@ export class OrcaRuntimeService {
|
||||
suffixFailureError?: string
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const renderGate = this.createClaudeAgentPromptRenderGate(ptyId)
|
||||
let wrotePasteBytes = false
|
||||
let completedPaste = false
|
||||
try {
|
||||
const chunks = iterateTerminalInputChunks(pastePayload)
|
||||
let chunk = chunks.next()
|
||||
while (!chunk.done) {
|
||||
const nextChunk = chunks.next()
|
||||
await options.beforeWrite?.(ptyId)
|
||||
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 = chunks.next()
|
||||
chunk = nextChunk
|
||||
if (!chunk.done) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
@@ -17460,10 +17469,16 @@ export class OrcaRuntimeService {
|
||||
if (wrotePasteBytes && !completedPaste) {
|
||||
this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
}
|
||||
renderGate?.dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS))
|
||||
if (renderGate) {
|
||||
await renderGate.wait()
|
||||
renderGate.dispose()
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS))
|
||||
}
|
||||
try {
|
||||
await options.beforeWrite?.(ptyId)
|
||||
} catch (error) {
|
||||
@@ -17478,6 +17493,87 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private createClaudeAgentPromptRenderGate(ptyId: string): {
|
||||
arm: () => void
|
||||
wait: () => Promise<void>
|
||||
dispose: () => void
|
||||
} | null {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if ((pty?.launchAgent ?? pty?.foregroundAgent) !== 'claude') {
|
||||
return null
|
||||
}
|
||||
let armed = false
|
||||
let observedMarker = false
|
||||
let settled = false
|
||||
let markerCarry = ''
|
||||
let quietTimer: NodeJS.Timeout | null = null
|
||||
let hardTimer: NodeJS.Timeout | null = null
|
||||
let resolveRender!: () => void
|
||||
const rendered = new Promise<void>((resolve) => {
|
||||
resolveRender = resolve
|
||||
})
|
||||
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (quietTimer) {
|
||||
clearTimeout(quietTimer)
|
||||
quietTimer = null
|
||||
}
|
||||
if (hardTimer) {
|
||||
clearTimeout(hardTimer)
|
||||
hardTimer = null
|
||||
}
|
||||
resolveRender()
|
||||
}
|
||||
const armQuietTimer = (): void => {
|
||||
if (quietTimer) {
|
||||
clearTimeout(quietTimer)
|
||||
}
|
||||
quietTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS)
|
||||
}
|
||||
const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => {
|
||||
if (!armed || settled) {
|
||||
return
|
||||
}
|
||||
if (!observedMarker) {
|
||||
const combined = markerCarry + data
|
||||
markerCarry = combined.slice(-(CLAUDE_AGENT_PROMPT_RENDER_MARKER.length - 1))
|
||||
if (!combined.includes(CLAUDE_AGENT_PROMPT_RENDER_MARKER)) {
|
||||
return
|
||||
}
|
||||
observedMarker = true
|
||||
}
|
||||
armQuietTimer()
|
||||
})
|
||||
return {
|
||||
arm: () => {
|
||||
armed = true
|
||||
markerCarry = ''
|
||||
},
|
||||
wait: async () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
hardTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS)
|
||||
await rendered
|
||||
},
|
||||
dispose: () => {
|
||||
unsubscribe()
|
||||
if (quietTimer) {
|
||||
clearTimeout(quietTimer)
|
||||
quietTimer = null
|
||||
}
|
||||
if (hardTimer) {
|
||||
clearTimeout(hardTimer)
|
||||
hardTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTerminal(
|
||||
handle: string,
|
||||
options?: {
|
||||
|
||||
Reference in New Issue
Block a user