From 891995fe9ea110d29440d2dc499bdb0efa8ddc6b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:22:07 -0800 Subject: [PATCH] fix(native-chat): infer interrupt after acknowledged Escape on chat Stop (#41285) A chat Stop wrote Escape directly to the runtime PTY, bypassing xterm onData so agents that emit no interrupt hook stayed Working in the sidebar. Send Stop's Escape via sendRawVerified and observe plain-escape against the pre-Escape status baseline only after write settlement. Fixes #31786 Co-authored-by: Zetao Qi <256975935+QZTnb@users.noreply.github.com> --- .../use-native-chat-interactive-send.test.tsx | 125 ++++++++++++++++++ .../send/use-native-chat-interactive-send.ts | 20 ++- 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.test.tsx diff --git a/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.test.tsx new file mode 100644 index 00000000000..bce39608f40 --- /dev/null +++ b/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../../../../shared/agent-status/contracts/agent-status-types' +import { AGENT_INTERRUPT_SETTLE_MS } from '../../../../../../../shared/agent-status/lifecycle/agent-interrupt-intent' + +const mocks = vi.hoisted(() => ({ + inferInterrupt: vi.fn(() => Promise.resolve(true)), + inferQuestionAnswered: vi.fn(() => Promise.resolve(true)), + sendRuntimePtyInputVerified: vi.fn((..._args: unknown[]) => Promise.resolve(true)) +})) + +vi.mock('@/runtime/hosted-terminals/input/runtime-terminal-verified-input', () => ({ + sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args) +})) + +import { useAppStore } from '../../../../../store/index' +import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const initialState = useAppStore.getInitialState() + +function workingStatus(prompt: string, startedAt: number): AgentStatusEntry { + return { + state: 'working', + prompt, + updatedAt: startedAt, + stateStartedAt: startedAt, + agentType: 'openclaude', + paneKey: PANE_KEY, + stateHistory: [] + } +} + +describe('useNativeChatInteractiveSend Stop interrupt inference', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + vi.clearAllMocks() + useAppStore.setState(initialState, true) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + agentStatus: { + inferQuestionAnswered: mocks.inferQuestionAnswered, + inferInterrupt: mocks.inferInterrupt + } + } + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.useRealTimers() + }) + + it.each([ + [true, 1], + [false, 0] + ] as const)( + 'gives an openclaude Stop (Escape accepted: %s) the interrupt inference a typed Escape gets', + async (accepted, inferences) => { + const startedAt = 10_000 + useAppStore.setState({ + agentStatusByPaneKey: { [PANE_KEY]: workingStatus('run sleep 60', startedAt) } + }) + mocks.sendRuntimePtyInputVerified.mockResolvedValueOnce(accepted) + const { result } = renderHook(() => + useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'openclaude') + ) + + await act(async () => result.current.cancel()) + expect(mocks.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', '\x1b', 'driving', { + requireWriteSettlement: true + }) + expect(mocks.inferInterrupt).not.toHaveBeenCalled() + + await act(async () => { + vi.advanceTimersByTime(AGENT_INTERRUPT_SETTLE_MS) + }) + + expect(mocks.inferInterrupt).toHaveBeenCalledTimes(inferences) + if (inferences > 0) { + expect(mocks.inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: startedAt, + baselineStateStartedAt: startedAt, + baselinePrompt: 'run sleep 60', + baselineAgentType: 'openclaude', + intent: 'plain-escape' + }) + } + } + ) + + // A slow remote acceptance must not settle a turn that started after the Stop. + it('infers a Stop from the status before its Escape, not a turn that began mid-send', async () => { + const startedAt = 10_000 + useAppStore.setState({ + agentStatusByPaneKey: { [PANE_KEY]: workingStatus('run sleep 60', startedAt) } + }) + let accept: (accepted: boolean) => void = () => {} + mocks.sendRuntimePtyInputVerified.mockReturnValueOnce( + new Promise((resolve) => { + accept = resolve + }) + ) + const { result } = renderHook(() => + useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'openclaude') + ) + + act(() => result.current.cancel()) + useAppStore.setState({ + agentStatusByPaneKey: { [PANE_KEY]: workingStatus('next prompt', startedAt + 1) } + }) + await act(async () => accept(true)) + await act(async () => { + vi.advanceTimersByTime(AGENT_INTERRUPT_SETTLE_MS) + }) + + expect(mocks.inferInterrupt).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.ts index 6e2a6392654..676346cb98d 100644 --- a/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/session/terminal/send/use-native-chat-interactive-send.ts @@ -23,6 +23,7 @@ import { type NativeChatSendHandle } from './native-chat-runtime-send' import { inferQuestionAnsweredFromCurrentStatus } from '../../../../terminal-pane/agent-status/input-inference/agent-question-answered-inference' +import { createAgentInterruptInference } from '../../../../terminal-pane/agent-status/input-inference/agent-interrupt-inference' // Agent TUIs use Escape to cancel questions or deny approvals. const ESC = '\x1b' @@ -157,8 +158,23 @@ export function useNativeChatInteractiveSend( inFlightRef.current = sendNativeChatAskAnswer(targetPtyId, [{ raw: ESC }, { raw: ESC }]) return } - sendRaw(ESC) - }, [agent, cancelInFlight, sendRaw, targetPtyId]) + // Why: this Escape bypasses xterm.onData; infer like an acknowledged typed Escape, from the status before it. + const statusBeforeEscape = useAppStore.getState().agentStatusByPaneKey[paneKey] ?? null + void sendRawVerified(ESC).then((accepted) => { + if (!accepted) { + return + } + void createAgentInterruptInference({ + paneKey, + getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[paneKey], + inferInterrupt: (request) => + window.api.agentStatus.inferInterrupt(request).catch((err) => { + console.warn('[agent-interrupt] native-chat inference failed:', err) + return false + }) + }).observeInputIntent('plain-escape', statusBeforeEscape) + }) + }, [agent, cancelInFlight, paneKey, sendRawVerified, targetPtyId]) return { sendAnswer, sendRaw, sendRawVerified, cancelPending: cancelInFlight, cancelAsk, cancel } }