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 } }