mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
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:
+125
@@ -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()
|
||||
})
|
||||
})
|
||||
+18
-2
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user