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>
This commit is contained in:
Neil
2026-11-11 15:22:07 -08:00
committed by GitHub
co-authored by Zetao Qi
parent 32bebf99fa
commit 891995fe9e
2 changed files with 143 additions and 2 deletions
@@ -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<boolean>((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()
})
})
@@ -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 }
}