From b8a84aea2d9af1030ddcc38d60ee1356f2d579ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:39:19 -0700 Subject: [PATCH] fix(agent-status): verify native chat answer delivery --- .../native-chat-runtime-send.test.ts | 29 ++++++++++++++--- .../native-chat/native-chat-runtime-send.ts | 24 +++++++++++--- .../use-native-chat-interactive-send.test.tsx | 13 ++++++++ .../use-native-chat-interactive-send.ts | 31 +++++++++---------- 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 865e21f3e00..4982bbda298 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -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((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)) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 57442992abe..f067064e047 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -162,13 +162,20 @@ export function sendNativeChatAskAnswer( return { cancel: () => {}, settleAfterMs: 0 } } const timers: ReturnType[] = [] - let delivered = true + const verifiedWrites: Promise[] = [] + 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) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 9010bc01057..27473887192 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -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') diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index f27d7f63be7..54fe294f8a7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -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,