fix(agent-status): verify native chat answer delivery

This commit is contained in:
Brennan Benson
2026-07-16 19:39:19 -07:00
parent ab094e462f
commit b8a84aea2d
4 changed files with 73 additions and 24 deletions
@@ -207,6 +207,7 @@ describe('sendNativeChatAskAnswer', () => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
sendRuntimePtyInput.mockReturnValue(true)
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
})
afterEach(() => {
vi.useRealTimers()
@@ -281,18 +282,38 @@ describe('sendNativeChatAskAnswer', () => {
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
})
it('reports delivery only after settling and suppresses it after cancellation', () => {
it('reports verified delivery only after settling and suppresses it after cancellation', async () => {
const onSettled = vi.fn()
sendRuntimePtyInput.mockReturnValueOnce(true).mockReturnValueOnce(false)
sendRuntimePtyInputVerified.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '\r' }], onSettled)
vi.advanceTimersByTime(handle.settleAfterMs)
await vi.advanceTimersByTimeAsync(handle.settleAfterMs)
expect(onSettled).toHaveBeenCalledExactlyOnceWith(false)
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
const canceledSettled = vi.fn()
const canceled = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }], canceledSettled)
canceled.cancel()
vi.runAllTimers()
await vi.runAllTimersAsync()
expect(canceledSettled).not.toHaveBeenCalled()
})
it('waits for remote acceptance before reporting delivery', async () => {
const onSettled = vi.fn()
let resolveAccepted!: (accepted: boolean) => void
sendRuntimePtyInputVerified.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveAccepted = resolve
})
)
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled)
await vi.advanceTimersByTimeAsync(handle.settleAfterMs)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2')
expect(onSettled).not.toHaveBeenCalled()
resolveAccepted(true)
await vi.waitFor(() => expect(onSettled).toHaveBeenCalledExactlyOnceWith(true))
})
})
@@ -162,13 +162,20 @@ export function sendNativeChatAskAnswer(
return { cancel: () => {}, settleAfterMs: 0 }
}
const timers: ReturnType<typeof setTimeout>[] = []
let delivered = true
const verifiedWrites: Promise<boolean>[] = []
let cancelled = false
groups.forEach((group, index) => {
timers.push(
setTimeout(() => {
const bytes = 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text)
if (!sendRuntimePtyInput(settings, ptyId, bytes)) {
delivered = false
if (onSettled) {
// Why: inference must use the remote host's acceptance result, not
// the fire-and-forget renderer dispatch result.
verifiedWrites.push(
sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false)
)
} else {
sendRuntimePtyInput(settings, ptyId, bytes)
}
}, index * NATIVE_CHAT_QUESTION_STEP_MS)
)
@@ -178,10 +185,19 @@ export function sendNativeChatAskAnswer(
if (onSettled) {
// Why: status inference must wait for every paced write and must not run
// after cancellation or a rejected runtime write.
timers.push(setTimeout(() => onSettled(delivered), settleAfterMs))
timers.push(
setTimeout(() => {
void Promise.all(verifiedWrites).then((results) => {
if (!cancelled) {
onSettled(results.every(Boolean))
}
})
}, settleAfterMs)
)
}
return {
cancel: () => {
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
@@ -94,6 +94,19 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
it('infers OpenClaude answers through its Claude-compatible selector path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'openclaude')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
expect(onSettled).toBeTypeOf('function')
onSettled?.(true)
expect(mocks.inferQuestionAnswered).toHaveBeenCalledOnce()
})
it('does nothing when no option is answered', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
@@ -86,23 +86,22 @@ export function useNativeChatInteractiveSend(
// Other agents' question tools commit a pasted answer, so send label text.
// Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which
// runs the same selector — takes the keystroke path too.
const onSettled =
agent === 'claude'
? (delivered: boolean): void => {
if (!delivered) {
return
}
inferQuestionAnsweredFromCurrentStatus({
paneKey,
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[paneKey],
inferQuestionAnswered: (request) =>
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
console.warn('[agent-question] native-chat inference failed:', err)
return false
})
})
const onSettled = shouldStepNativeChatAskAnswer(agent)
? (delivered: boolean): void => {
if (!delivered) {
return
}
: undefined
inferQuestionAnsweredFromCurrentStatus({
paneKey,
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[paneKey],
inferQuestionAnswered: (request) =>
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
console.warn('[agent-question] native-chat inference failed:', err)
return false
})
})
}
: undefined
const handle: NativeChatSendHandle = shouldStepNativeChatAskAnswer(agent)
? sendNativeChatAskAnswer(
settings,