mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(native-chat): preserve send pacing after renderer stalls
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
export type NativeChatPtySendQueueHandle = {
|
||||
cancel: () => void
|
||||
settleAfterMs: number
|
||||
settled: Promise<void>
|
||||
bodyStarted: () => boolean
|
||||
finished: () => boolean
|
||||
}
|
||||
@@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend(
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
let release: (() => void) | null = null
|
||||
|
||||
const finishEntry = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
const resolve = release
|
||||
release = null
|
||||
resolve?.()
|
||||
}
|
||||
|
||||
const delay = (ms: number, fn: () => void): void => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
@@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend(
|
||||
timers.push(timer)
|
||||
}
|
||||
|
||||
const markFinished = (): void => {
|
||||
finished = true
|
||||
}
|
||||
|
||||
const markSubmitted = (): void => {
|
||||
submitted = true
|
||||
finishEntry()
|
||||
}
|
||||
|
||||
const execute = (): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
if (cancelled) {
|
||||
markFinished()
|
||||
release = null
|
||||
finished = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
@@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend(
|
||||
start({ isCancelled: () => cancelled, delay, markSubmitted })
|
||||
if (durationMs <= 0) {
|
||||
markSubmitted()
|
||||
markFinished()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Why: always release after the declared duration so a cancel mid-flight
|
||||
// cannot stall the per-pty queue forever.
|
||||
const done = setTimeout(() => {
|
||||
markFinished()
|
||||
resolve()
|
||||
}, durationMs)
|
||||
timers.push(done)
|
||||
})
|
||||
|
||||
const runPromise =
|
||||
@@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend(
|
||||
|
||||
const settleQueueEntry = (): void => {
|
||||
state.depth = Math.max(0, state.depth - 1)
|
||||
markFinished()
|
||||
finished = true
|
||||
dropHandle()
|
||||
// Why: drop the per-pty record once nothing is in flight so the map does not
|
||||
// accumulate one permanent entry per pty over a long, multi-pane session.
|
||||
@@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend(
|
||||
}
|
||||
}
|
||||
|
||||
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
const settled = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
state.tail = settled
|
||||
|
||||
const handle: NativeChatPtySendQueueHandle = {
|
||||
cancel: () => {
|
||||
@@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend(
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const shouldClear = bodyStarted && !submitted
|
||||
markFinished()
|
||||
// Why: refund only THIS sequence's charged window rather than collapsing
|
||||
// freeAt to now — later queued sends still hold the line, so a blanket
|
||||
// reset would understate the next enqueue's settle time and let a send
|
||||
// card drop while a queued Enter is still pending.
|
||||
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
|
||||
release?.()
|
||||
release = null
|
||||
finishEntry()
|
||||
dropHandle()
|
||||
if (shouldClear) {
|
||||
options?.onCancelUnsubmitted?.()
|
||||
}
|
||||
},
|
||||
settleAfterMs,
|
||||
settled,
|
||||
bodyStarted: () => bodyStarted,
|
||||
finished: () => finished
|
||||
}
|
||||
|
||||
@@ -69,6 +69,37 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT])
|
||||
})
|
||||
|
||||
it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => {
|
||||
vi.useRealTimers()
|
||||
const writeTimes = new Map<string, number>()
|
||||
sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => {
|
||||
writeTimes.set(bytes, performance.now())
|
||||
return true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'queued')
|
||||
|
||||
const blockedUntil =
|
||||
performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50
|
||||
while (performance.now() < blockedUntil) {
|
||||
// Simulate a renderer long task delaying both nominal deadlines.
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), {
|
||||
timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000
|
||||
})
|
||||
await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true))
|
||||
expect(
|
||||
writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))!
|
||||
).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20)
|
||||
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
|
||||
writes().indexOf(buildNativeChatPasteBytes('queued'))
|
||||
)
|
||||
})
|
||||
|
||||
it('widens to a maximal burst when the draft is still observed on the line', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
|
||||
@@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => {
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not let a canceled queued send stall the sends behind it', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first')
|
||||
const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled')
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'third')
|
||||
canceled.cancel()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('first'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('third')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not serialize sends across different PTYs', () => {
|
||||
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
|
||||
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
|
||||
|
||||
@@ -63,6 +63,8 @@ export type NativeChatSendHandle = {
|
||||
cancel: () => void
|
||||
/** Time after which every scheduled write has fired and the handle can drop. */
|
||||
settleAfterMs: number
|
||||
/** Actual completion, which can outlive the nominal schedule if the renderer stalls. */
|
||||
settled?: Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
@@ -97,7 +99,13 @@ function clearThenWrite(
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
|
||||
if (!confirmCleared()) {
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = confirmCleared()
|
||||
} catch {
|
||||
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
|
||||
}
|
||||
if (!cleared) {
|
||||
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX)
|
||||
}
|
||||
writeBody()
|
||||
@@ -130,15 +138,17 @@ export function sendNativeChatMessage(
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
// Schedule from the actual body write: an overdue clear-confirm callback
|
||||
// must not collapse the required body-to-Enter gap after a renderer stall.
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -225,16 +235,6 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
const clearConfirmMs = clearConfirmDurationMs(options)
|
||||
if (trimmedText.length > 0) {
|
||||
delay(clearConfirmMs + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
})
|
||||
}
|
||||
delay(durationMs, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
@@ -242,6 +242,20 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,4 +66,30 @@ describe('useNativeChatSendLifecycle', () => {
|
||||
expect(settled.cancel).not.toHaveBeenCalled()
|
||||
expect(onPendingSendCanceled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a renderer-stalled send cancelable past its nominal schedule', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveSettled!: () => void
|
||||
const stalled = {
|
||||
...handle(640),
|
||||
settled: new Promise<void>((resolve) => {
|
||||
resolveSettled = resolve
|
||||
})
|
||||
}
|
||||
const onPendingSendCanceled = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled),
|
||||
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
|
||||
)
|
||||
|
||||
act(() => result.current.trackPendingSend(stalled, 'pending-1'))
|
||||
act(() => vi.advanceTimersByTime(stalled.settleAfterMs + 1_000))
|
||||
rerender({ targetPtyId: 'pty-2' })
|
||||
|
||||
expect(stalled.cancel).toHaveBeenCalledOnce()
|
||||
expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1')
|
||||
await act(async () => {
|
||||
resolveSettled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle(
|
||||
const pendingSendHandlesRef = useRef(
|
||||
new Map<
|
||||
NativeChatSendHandle,
|
||||
{ cleanupTimer: ReturnType<typeof setTimeout>; pendingId?: string }
|
||||
{ cleanupTimer: ReturnType<typeof setTimeout> | null; pendingId?: string }
|
||||
>()
|
||||
)
|
||||
const cancelPendingSends = useCallback(() => {
|
||||
for (const [handle, entry] of pendingSendHandlesRef.current) {
|
||||
const { cleanupTimer, pendingId } = entry
|
||||
clearTimeout(cleanupTimer)
|
||||
if (cleanupTimer !== null) {
|
||||
clearTimeout(cleanupTimer)
|
||||
}
|
||||
handle.cancel()
|
||||
if (pendingId) {
|
||||
onPendingSendCanceled?.(pendingId)
|
||||
@@ -29,13 +31,22 @@ export function useNativeChatSendLifecycle(
|
||||
pendingSendHandlesRef.current.clear()
|
||||
}, [onPendingSendCanceled])
|
||||
const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => {
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
const entry = {
|
||||
cleanupTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
...(pendingId ? { pendingId } : {})
|
||||
}
|
||||
pendingSendHandlesRef.current.set(handle, entry)
|
||||
if (handle.settled) {
|
||||
void handle.settled.then(() => {
|
||||
if (pendingSendHandlesRef.current.get(handle) === entry) {
|
||||
pendingSendHandlesRef.current.delete(handle)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
entry.cleanupTimer = setTimeout(() => {
|
||||
pendingSendHandlesRef.current.delete(handle)
|
||||
}, handle.settleAfterMs)
|
||||
pendingSendHandlesRef.current.set(handle, {
|
||||
cleanupTimer,
|
||||
...(pendingId ? { pendingId } : {})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Why: delayed Enter/image writes belong to the exact PTY target. A pane
|
||||
|
||||
Reference in New Issue
Block a user