fix(agent-status): clear answered Claude question waits at answer time (#9074)

* fix(agent-status): clear answered Claude question waits at answer time

An answered AskUserQuestion left the amber "waiting" indicator on sidebar
rows and tabs until the agent's next tool hook or turn end — unbounded
linger while the model thinks or streams after the answer (measured 17s
for a 1000-word reply, 44s for 3000 words).

Root cause is an event-shape change: newer Claude reports the
AskUserQuestion wait as PermissionRequest (not the PreToolUse shape #7852
special-cased), so the wait inherited real-permission stickiness and
shouldKeepClaudePermissionVisible swallowed the answer-time
PostToolUse(AskUserQuestion) working event — the identity match can never
succeed because the question's PermissionRequest carries no inheritable
tool_use_id. That silently undid #8311 for questions.

Two scoped changes, both keyed on the tool name rather than the hook
event name:

- Sticky permission hold now exempts AskUserQuestion waits, so the real
  answer-time hook (when Claude sends one) clears the wait as #8311
  intended.
- New guarded inference for the hook Claude may never send: the submit
  keystroke (Enter or digit quick-select) into a pane whose fresh status
  is a waiting AskUserQuestion synthesizes the post-answer state, exactly
  mirroring the existing interrupt inference (renderer baseline capture,
  main-process re-validation, listener lead-state sync so child-driven
  refreshes cannot resurrect the dismissed question).

Real permission waits (other tools) keep their sticky semantics; batched
input and pastes never match the submit classifier.

Verified live against a real claude CLI: waiting -> working within ~50ms
of both Enter and digit answers, question card dropped, unanswered
questions still hold amber, permission stickiness covered by tests.

* fix(agent-status): guard question answer inference

Keep multi-question, multi-select, and free-text selector interactions waiting until the full prompt is submitted. Wire native-chat answers into the same guarded inference only after every paced runtime write succeeds, with cancellation and delivery-failure coverage.

* chore(skills): refresh manifest for rc.2

* fix(agent-status): verify native chat answer delivery

* fix(agent-status): await verified question delivery

* fix(agent-status): pin native-chat answer baseline before delivery

The native-chat question-answered inference read the live pane status at
settle time (after the paced send + remote acceptance, which can span
seconds on SSH). If a replacement AskUserQuestion became current in that
window, the settle callback minted a fresh baseline from the new question
and the server cleared *its* wait — dismissing a question the user never
answered.

Capture the answered question's baseline before delivery and have the
inference getter return it, so the server re-validates against the pinned
baseline and rejects a changed status — the same capture-then-revalidate
contract the terminal keystroke path already uses. Also hoist the
shouldStepNativeChatAskAnswer predicate to a single evaluation.

Regression test swaps the live status between sendAnswer and settle and
asserts the answered question's baseline is used (fails against the prior
live-read getter).
This commit is contained in:
Brennan Benson
2026-07-17 12:47:04 -07:00
committed by GitHub
parent b67106805f
commit 2cb5d4e149
19 changed files with 1018 additions and 55 deletions
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
@@ -73,7 +73,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('keeps the card retryable when no PTY answer was sent', () => {
mocks.sendAnswer.mockReturnValue(0)
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 0, waitsForVerifiedDelivery: false })
renderCard()
chooseSpacesAndSubmit()
@@ -84,7 +84,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('cancels delayed PTY writes when the owning card unmounts', () => {
mocks.sendAnswer.mockReturnValue(5_000)
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
const rendered = renderCard()
chooseSpacesAndSubmit()
@@ -95,7 +95,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('cancels delayed PTY writes when desktop send authority is lost', () => {
mocks.sendAnswer.mockReturnValue(5_000)
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
const rendered = renderCard()
chooseSpacesAndSubmit()
@@ -105,7 +105,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('shows the paced send as busy and freezes the snapshotted answer', () => {
mocks.sendAnswer.mockReturnValue(5_000)
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
renderCard()
chooseSpacesAndSubmit()
@@ -117,7 +117,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('cancels the old answer sequence when a replacement prompt arrives', () => {
mocks.sendAnswer.mockReturnValue(5_000)
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
const rendered = renderCard()
chooseSpacesAndSubmit()
@@ -135,4 +135,34 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
expect(mocks.cancelPending).toHaveBeenCalledOnce()
expect(screen.getByText('Choose a shell?')).toBeInTheDocument()
})
it('keeps a verified send visible until delivery succeeds', () => {
let settleDelivery: ((delivered: boolean) => void) | undefined
mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => {
settleDelivery = onDeliverySettled
return { settleAfterMs: 500, waitsForVerifiedDelivery: true }
})
renderCard()
chooseSpacesAndSubmit()
expect(screen.getByRole('button', { name: 'Sending…' })).toBeDisabled()
act(() => settleDelivery?.(true))
expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument()
})
it('restores a verified send for retry when delivery is rejected', () => {
let settleDelivery: ((delivered: boolean) => void) | undefined
mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => {
settleDelivery = onDeliverySettled
return { settleAfterMs: 500, waitsForVerifiedDelivery: true }
})
renderCard()
chooseSpacesAndSubmit()
act(() => settleDelivery?.(false))
expect(screen.getByRole('button', { name: 'Send answer' })).toBeEnabled()
expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument()
})
})
@@ -107,23 +107,42 @@ export function NativeChatInteractiveCard({
return
}
submittingRef.current = true
const settleMs = sendAnswer(card.prompt, selections)
if (settleMs <= 0) {
// Keep the actionable card visible when its PTY disappeared between
// render and submit; the next live target update can make it retryable.
submittingRef.current = false
return
}
setSubmitting(true)
// Hold the card until the paced write finishes, then mark it answered
// (which hides it and restores the composer).
dismissTimerRef.current = setTimeout(() => {
cancelPending()
const dismissAnsweredCard = (): void => {
setDismissedKey(cardKey)
submittingRef.current = false
setSubmitting(false)
dismissTimerRef.current = null
}, settleMs)
}
const keepRejectedAnswerVisible = (): void => {
submittingRef.current = false
setSubmitting(false)
}
const result = sendAnswer(card.prompt, selections, (delivered) => {
if (delivered) {
dismissAnsweredCard()
} else {
keepRejectedAnswerVisible()
}
})
if (result.settleAfterMs <= 0) {
// Keep the actionable card visible when its PTY disappeared between
// render and submit; the next live target update can make it retryable.
keepRejectedAnswerVisible()
return
}
setSubmitting(true)
if (result.waitsForVerifiedDelivery) {
// Why: remote acceptance can outlive the keystroke pacing window.
// Keep the card until delivery is proven instead of cancelling the
// inference callback at the old fixed dismissal deadline.
return
}
// Hold the card until the paced write finishes, then mark it answered
// (which hides it and restores the composer).
dismissTimerRef.current = setTimeout(() => {
cancelPending()
dismissAnsweredCard()
}, result.settleAfterMs)
}}
onCancel={() => {
clearDismissTimer()
@@ -166,7 +166,7 @@ function NativeChatResolvedView({
const canSend = useNativeChatCanSend(targetPtyId)
// Reuse the verified composer send path for interactive cards and composer
// stop (Stop sends ESC, the agent-TUI interrupt key).
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent)
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent)
const [workingInterrupted, setWorkingInterrupted] = useState(false)
// True while a question card owns the input region, so the composer is hidden.
const [questionActive, setQuestionActive] = useState(false)
@@ -33,6 +33,7 @@ describe('sendNativeChatMessage', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
sendRuntimePtyInput.mockReturnValue(true)
})
afterEach(() => {
vi.useRealTimers()
@@ -205,6 +206,8 @@ describe('sendNativeChatAskAnswer', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
sendRuntimePtyInput.mockReturnValue(true)
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
})
afterEach(() => {
vi.useRealTimers()
@@ -278,4 +281,39 @@ describe('sendNativeChatAskAnswer', () => {
// Only the first keystroke landed; the rest were cancelled.
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
})
it('reports verified delivery only after settling and suppresses it after cancellation', async () => {
const onSettled = vi.fn()
sendRuntimePtyInputVerified.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '\r' }], onSettled)
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()
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<boolean>((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))
})
})
@@ -155,27 +155,54 @@ export function submitNativeChatPrompt(
export function sendNativeChatAskAnswer(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
groups: AskAnswerKeyGroup[]
groups: AskAnswerKeyGroup[],
onSettled?: (delivered: boolean) => void
): NativeChatSendHandle {
if (groups.length === 0) {
return { cancel: () => {}, settleAfterMs: 0 }
}
const timers: ReturnType<typeof setTimeout>[] = []
const verifiedWrites: Promise<boolean>[] = []
let cancelled = false
groups.forEach((group, index) => {
timers.push(
setTimeout(() => {
const bytes = 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text)
sendRuntimePtyInput(settings, ptyId, bytes)
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)
)
})
const settleAfterMs =
(groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
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(() => {
void Promise.all(verifiedWrites).then((results) => {
if (!cancelled) {
onSettled(results.every(Boolean))
}
})
}, settleAfterMs)
)
}
return {
cancel: () => {
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
},
// Hold the card until the last keystroke has fired and its submit gap passed.
settleAfterMs: (groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
settleAfterMs
}
}
@@ -5,9 +5,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
cancel: vi.fn(),
inferQuestionAnswered: vi.fn(() => Promise.resolve(true)),
sendRuntimePtyInput: vi.fn(),
sendNativeChatAskAnswer: vi.fn(),
sendNativeChatMessage: vi.fn()
sendNativeChatMessage: vi.fn(),
// Mutable so a test can swap the live status between sendAnswer and settle.
storeState: { agentStatusByPaneKey: {} as Record<string, unknown> }
}))
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
const waitingQuestion = {
state: 'waiting' as const,
prompt: 'pick one',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'claude' as const,
paneKey: PANE_KEY,
stateHistory: [],
toolName: 'AskUserQuestion'
}
vi.mock('../../store', () => ({
useAppStore: {
getState: () => mocks.storeState
}
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
@@ -33,13 +54,20 @@ const PROMPT: AskPrompt = {
describe('useNativeChatInteractiveSend', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState = { agentStatusByPaneKey: { [PANE_KEY]: waitingQuestion } }
Object.defineProperty(window, 'api', {
configurable: true,
value: { agentStatus: { inferQuestionAnswered: mocks.inferQuestionAnswered } }
})
const handle = { cancel: mocks.cancel, settleAfterMs: 500 }
mocks.sendNativeChatAskAnswer.mockReturnValue(handle)
mocks.sendNativeChatMessage.mockReturnValue(handle)
})
it('routes a non-Claude answer through the pasted-text send path', () => {
const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'codex'))
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
@@ -53,7 +81,9 @@ describe('useNativeChatInteractiveSend', () => {
})
it('routes a Claude answer through the option-number keystroke path', () => {
const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude'))
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
@@ -61,27 +91,43 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
[{ raw: '2' }]
[{ raw: '2' }],
expect.any(Function)
)
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
it('does nothing when no option is answered', () => {
const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude'))
it('infers OpenClaude answers through its Claude-compatible selector path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'openclaude')
)
let settleMs = -1
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')
)
let resultValue: ReturnType<typeof result.current.sendAnswer> | undefined
act(() => {
settleMs = result.current.sendAnswer(PROMPT, [{ indices: [] }])
resultValue = result.current.sendAnswer(PROMPT, [{ indices: [] }])
})
expect(settleMs).toBe(0)
expect(resultValue).toEqual({ settleAfterMs: 0, waitsForVerifiedDelivery: false })
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
it('cancels delayed answer writes when the PTY target changes', () => {
const { result, rerender } = renderHook(
({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', targetPtyId, 'codex'),
({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, targetPtyId, 'codex'),
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
)
@@ -91,8 +137,22 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.cancel).toHaveBeenCalledOnce()
})
it('cancels delayed answer writes when the pane identity changes', () => {
const { result, rerender } = renderHook(
({ paneKey }) => useNativeChatInteractiveSend('tab-1', paneKey, 'pty-1', 'claude'),
{ initialProps: { paneKey: PANE_KEY } }
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [0] }]))
rerender({ paneKey: 'tab-1:22222222-2222-4222-8222-222222222222' })
expect(mocks.cancel).toHaveBeenCalledOnce()
})
it('cancels delayed answer writes before interrupting the active PTY', () => {
const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude'))
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
act(() => result.current.cancel())
@@ -106,7 +166,9 @@ describe('useNativeChatInteractiveSend', () => {
})
it('can cancel delayed writes without interrupting the replacement prompt', () => {
const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude'))
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
act(() => result.current.cancelPending())
@@ -114,4 +176,81 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.cancel).toHaveBeenCalledOnce()
expect(mocks.sendRuntimePtyInput).not.toHaveBeenCalled()
})
it('infers a Claude question answer only after every runtime write was delivered', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled()
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
expect(onSettled).toBeTypeOf('function')
onSettled?.(false)
expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled()
onSettled?.(true)
expect(mocks.inferQuestionAnswered).toHaveBeenCalledExactlyOnceWith({
paneKey: PANE_KEY,
baselineUpdatedAt: waitingQuestion.updatedAt,
baselineStateStartedAt: waitingQuestion.stateStartedAt,
baselinePrompt: 'pick one',
baselineAgentType: 'claude'
})
})
it('infers the answered question baseline, not a replacement that became current mid-send', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
// Answer question A while it is the current waiting question.
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
// A different AskUserQuestion becomes current before the paced send settles.
mocks.storeState = {
agentStatusByPaneKey: {
[PANE_KEY]: {
...waitingQuestion,
prompt: 'second question',
updatedAt: waitingQuestion.updatedAt + 5000,
stateStartedAt: waitingQuestion.stateStartedAt + 5000
}
}
}
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
onSettled?.(true)
// The baseline is question A's (captured before delivery), so the server can
// reject it against the now-current question B instead of clearing B's wait.
expect(mocks.inferQuestionAnswered).toHaveBeenCalledExactlyOnceWith({
paneKey: PANE_KEY,
baselineUpdatedAt: waitingQuestion.updatedAt,
baselineStateStartedAt: waitingQuestion.stateStartedAt,
baselinePrompt: 'pick one',
baselineAgentType: 'claude'
})
})
it('reports verified delivery settlement to the question card', () => {
const onDeliverySettled = vi.fn()
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
let sendResult: ReturnType<typeof result.current.sendAnswer> | undefined
act(() => {
sendResult = result.current.sendAnswer(PROMPT, [{ indices: [1] }], onDeliverySettled)
})
expect(sendResult).toEqual({ settleAfterMs: 500, waitsForVerifiedDelivery: true })
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
onSettled?.(false)
expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(false)
act(() => result.current.cancelPending())
expect(mocks.cancel).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,5 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '../../store'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AgentType } from '../../../../shared/native-chat-types'
@@ -15,16 +16,20 @@ import {
sendNativeChatMessage,
type NativeChatSendHandle
} from './native-chat-runtime-send'
import { inferQuestionAnsweredFromCurrentStatus } from '../terminal-pane/agent-question-answered-inference'
// ESC is the agent-TUI interrupt/cancel key over the PTY (matches how the
// composer forwards Escape). Used to cancel a question or deny an approval.
const ESC = '\x1b'
export type NativeChatInteractiveSend = {
/** Deliver the answer to an AskUserQuestion prompt. Returns the ms after which
* every scheduled write has fired (0 if nothing was sent) so the caller can
* keep the card up until the send settles. */
sendAnswer: (prompt: AskPrompt, selections: AskAnswerSelection[]) => number
/** Deliver the answer to an AskUserQuestion prompt. Claude-format selectors
* verify every runtime write before reporting settlement. */
sendAnswer: (
prompt: AskPrompt,
selections: AskAnswerSelection[],
onDeliverySettled?: (delivered: boolean) => void
) => { settleAfterMs: number; waitsForVerifiedDelivery: boolean }
/** Send a raw control string (e.g. an approval option number or ESC) as-is. */
sendRaw: (raw: string) => void
/** Stop delayed writes without interrupting the agent. */
@@ -44,6 +49,7 @@ export type NativeChatInteractiveSend = {
*/
export function useNativeChatInteractiveSend(
terminalTabId: string,
paneKey: string,
targetPtyId: string | null,
agent: AgentType
): NativeChatInteractiveSend {
@@ -57,7 +63,10 @@ export function useNativeChatInteractiveSend(
}, [])
// Why: a split can be rebound without unmounting this view. Cancel during
// commit so no delayed answer write can race the replacement PTY.
useLayoutEffect(() => cancelInFlight, [cancelInFlight, targetPtyId, terminalTabId])
useLayoutEffect(
() => cancelInFlight,
[agent, cancelInFlight, paneKey, targetPtyId, terminalTabId]
)
const sendRaw = useCallback(
(raw: string) => {
@@ -70,9 +79,13 @@ export function useNativeChatInteractiveSend(
)
const sendAnswer = useCallback(
(prompt: AskPrompt, selections: AskAnswerSelection[]): number => {
(
prompt: AskPrompt,
selections: AskAnswerSelection[],
onDeliverySettled?: (delivered: boolean) => void
): { settleAfterMs: number; waitsForVerifiedDelivery: boolean } => {
if (!targetPtyId || !hasAskAnswer(prompt, selections)) {
return 0
return { settleAfterMs: 0, waitsForVerifiedDelivery: false }
}
// Cancel any prior in-flight answer before starting a new one.
cancelInFlight()
@@ -83,13 +96,57 @@ 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 handle: NativeChatSendHandle = shouldStepNativeChatAskAnswer(agent)
? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections))
const stepsAnswer = shouldStepNativeChatAskAnswer(agent)
// Why: pin the answered question's baseline BEFORE delivery. A late settle
// callback (paced writes + remote acceptance can span seconds on SSH) must
// not read the live status and mint a fresh baseline for a replacement
// question that became current meanwhile — that would clear the new
// question's wait. The server re-validates this captured baseline and
// rejects a changed status, matching the terminal keystroke path.
const questionStatusBaseline = stepsAnswer
? useAppStore.getState().agentStatusByPaneKey[paneKey]
: undefined
let settledHandle: NativeChatSendHandle | null = null
const onSettled = stepsAnswer
? (delivered: boolean): void => {
if (settledHandle && inFlightRef.current === settledHandle) {
// Why: a completed verified send otherwise retains its timers,
// promises, and prompt callback until the next send or unmount.
inFlightRef.current = null
}
if (delivered) {
inferQuestionAnsweredFromCurrentStatus({
paneKey,
getStatusEntry: () => questionStatusBaseline,
inferQuestionAnswered: (request) =>
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
console.warn('[agent-question] native-chat inference failed:', err)
return false
})
})
}
onDeliverySettled?.(delivered)
}
: undefined
const handle: NativeChatSendHandle = stepsAnswer
? sendNativeChatAskAnswer(
settings,
targetPtyId,
buildAskAnswerKeys(prompt, selections),
onSettled
)
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections))
// Why: native-chat answer writes bypass xterm.onData. Infer only after
// every paced selector write has fired, so an early digit in a multi-step
// answer cannot dismiss the wait or cancel the remaining writes.
settledHandle = handle
inFlightRef.current = handle
return handle.settleAfterMs
return {
settleAfterMs: handle.settleAfterMs,
waitsForVerifiedDelivery: onSettled !== undefined
}
},
[terminalTabId, targetPtyId, agent, cancelInFlight]
[terminalTabId, paneKey, targetPtyId, agent, cancelInFlight]
)
// Stop/cancel: drop any pending answer writes, then send ESC to interrupt.
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference'
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
const SINGLE_SELECT_PROMPT = JSON.stringify({
questions: [
{
question: 'pick a color',
multiSelect: false,
options: [{ label: 'red' }, { label: 'blue' }]
}
]
})
function makeWaitingQuestionEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'waiting',
prompt: 'pick a color',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'claude',
paneKey: PANE_KEY,
stateHistory: [],
toolName: 'AskUserQuestion',
interactivePrompt: SINGLE_SELECT_PROMPT,
...overrides
}
}
function makeInference(entry: AgentStatusEntry | undefined) {
const inferQuestionAnswered = vi.fn()
const inference = createAgentQuestionAnsweredInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferQuestionAnswered,
now: () => 2_000
})
return { inference, inferQuestionAnswered }
}
describe('agent question-answered inference', () => {
it('reports the baseline when Enter is sent to a waiting question pane', () => {
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
inference.observeSentTerminalInput('\r')
expect(inferQuestionAnswered).toHaveBeenCalledExactlyOnceWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'pick a color',
baselineAgentType: 'claude'
})
})
it('accepts kitty-keyboard Enter encodings', () => {
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
inference.observeSentTerminalInput('\x1b[13u')
expect(inferQuestionAnswered).toHaveBeenCalledOnce()
})
it('accepts a bare digit quick-select, which submits without Enter', () => {
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
inference.observeSentTerminalInput('2')
expect(inferQuestionAnswered).toHaveBeenCalledOnce()
})
it('keeps waiting when a digit only advances or toggles a partial answer', () => {
const prompts = [
JSON.stringify({
questions: [
{ question: 'first?', multiSelect: false, options: [{ label: 'A' }] },
{ question: 'second?', multiSelect: false, options: [{ label: 'B' }] }
]
}),
JSON.stringify({
questions: [
{
question: 'pick several',
multiSelect: true,
options: [{ label: 'A' }, { label: 'B' }]
}
]
})
]
for (const interactivePrompt of prompts) {
const { inference, inferQuestionAnswered } = makeInference(
makeWaitingQuestionEntry({ interactivePrompt })
)
inference.observeSentTerminalInput('1')
inference.observeSentTerminalInput('\r')
expect(inferQuestionAnswered).not.toHaveBeenCalled()
}
})
it('keeps waiting when the synthetic free-text row is selected', () => {
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
// Two declared options means 3 opens Claude's synthetic "Type something" row.
inference.observeSentTerminalInput('3')
expect(inferQuestionAnswered).not.toHaveBeenCalled()
})
it('keeps waiting when capped or malformed prompt JSON hides the question shape', () => {
const { inference, inferQuestionAnswered } = makeInference(
makeWaitingQuestionEntry({ interactivePrompt: '{"questions":[' })
)
inference.observeSentTerminalInput('\r')
expect(inferQuestionAnswered).not.toHaveBeenCalled()
})
it('keeps the legacy Enter fallback when the hook omitted tool input', () => {
const { inference, inferQuestionAnswered } = makeInference(
makeWaitingQuestionEntry({ interactivePrompt: undefined })
)
inference.observeSentTerminalInput('\r')
expect(inferQuestionAnswered).toHaveBeenCalledOnce()
})
it('does not read status for ordinary terminal input', () => {
const getStatusEntry = vi.fn(() => makeWaitingQuestionEntry())
const inferQuestionAnswered = vi.fn()
const inference = createAgentQuestionAnsweredInference({
paneKey: PANE_KEY,
getStatusEntry,
inferQuestionAnswered
})
inference.observeSentTerminalInput('ordinary typing')
expect(getStatusEntry).not.toHaveBeenCalled()
expect(inferQuestionAnswered).not.toHaveBeenCalled()
})
it('ignores non-submit input, batched keystrokes, and pastes', () => {
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
inference.observeSentTerminalInput('\x1b')
inference.observeSentTerminalInput('a')
inference.observeSentTerminalInput('0')
inference.observeSentTerminalInput('12')
inference.observeSentTerminalInput('yes\r')
inference.observeSentTerminalInput('\x1b[200~line one\nline two\x1b[201~')
expect(inferQuestionAnswered).not.toHaveBeenCalled()
})
it('ignores panes without a fresh waiting AskUserQuestion status', () => {
const cases: (AgentStatusEntry | undefined)[] = [
undefined,
makeWaitingQuestionEntry({ state: 'working' }),
makeWaitingQuestionEntry({ toolName: 'Bash' }),
makeWaitingQuestionEntry({ agentType: 'codex' }),
// Why: a stale wait past the freshness horizon no longer renders amber,
// so a keystroke must not synthesize activity for it.
makeWaitingQuestionEntry({ updatedAt: -100_000_000, stateStartedAt: -100_000_000 })
]
for (const entry of cases) {
const { inference, inferQuestionAnswered } = makeInference(entry)
inference.observeSentTerminalInput('\r')
expect(inferQuestionAnswered).not.toHaveBeenCalled()
}
})
})
@@ -0,0 +1,85 @@
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import {
isAskUserQuestionTool,
isPotentialQuestionAnsweredSubmitInput,
isQuestionAnsweredSubmitInput,
type AgentQuestionAnsweredInferenceRequest
} from '../../../../shared/agent-question-answered-intent'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
export type AgentQuestionAnsweredInference = {
observeSentTerminalInput(data: string): void
}
type AgentQuestionAnsweredInferenceDeps = {
paneKey: string
getStatusEntry: () => AgentStatusEntry | undefined
inferQuestionAnswered: (
request: AgentQuestionAnsweredInferenceRequest
) => boolean | Promise<boolean> | void
now?: () => number
}
function inferQuestionAnsweredFromEntry(
deps: AgentQuestionAnsweredInferenceDeps,
entry: AgentStatusEntry | undefined
): boolean {
const now = deps.now ?? Date.now
if (
!entry ||
entry.state !== 'waiting' ||
entry.agentType !== 'claude' ||
!isAskUserQuestionTool(entry.toolName) ||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS)
) {
return false
}
void deps.inferQuestionAnswered({
paneKey: deps.paneKey,
baselineUpdatedAt: entry.updatedAt,
baselineStateStartedAt: entry.stateStartedAt,
baselinePrompt: entry.prompt,
baselineAgentType: entry.agentType
})
return true
}
/** Completion signal for answer surfaces that write directly to the runtime
* instead of xterm (notably native chat). The same fresh-status baseline is
* used, so a real hook that wins the race prevents the fallback IPC. */
export function inferQuestionAnsweredFromCurrentStatus(
deps: AgentQuestionAnsweredInferenceDeps
): boolean {
return inferQuestionAnsweredFromEntry(deps, deps.getStatusEntry())
}
/** Sibling of the interrupt inference for a hook Claude never sends: answering
* an AskUserQuestion emits no event, so the submit keystroke into the waiting
* pane is the only "question dealt with" signal. Unlike interrupts there is
* no expected real hook to settle for, so the inference fires immediately —
* the main process re-validates the baseline, so a racing hook always wins. */
export function createAgentQuestionAnsweredInference({
paneKey,
getStatusEntry,
inferQuestionAnswered,
now = () => Date.now()
}: AgentQuestionAnsweredInferenceDeps): AgentQuestionAnsweredInference {
return {
observeSentTerminalInput(data) {
// Why: ordinary terminal input is the hot path. Reject it with one
// constant-time membership check before reading Zustand or parsing the
// bounded interactive-prompt JSON.
if (!isPotentialQuestionAnsweredSubmitInput(data)) {
return
}
const entry = getStatusEntry()
if (!entry || !isQuestionAnsweredSubmitInput(data, entry.interactivePrompt)) {
return
}
inferQuestionAnsweredFromEntry({ paneKey, getStatusEntry, inferQuestionAnswered, now }, entry)
}
}
}
@@ -162,6 +162,7 @@ import {
isCtrlCKeyEvent,
isPlainEscapeKeyEvent
} from './agent-interrupt-inference'
import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference'
import {
AGENT_INTERRUPT_SETTLE_MS,
type AgentInterruptInputIntent
@@ -1771,6 +1772,15 @@ export function connectPanePty(
})
}
})
const questionAnsweredInference = createAgentQuestionAnsweredInference({
paneKey: cacheKey,
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
inferQuestionAnswered: (request) =>
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
console.warn('[agent-question] inferQuestionAnswered failed:', err)
return false
})
})
const dropCommandFinishedStatusIfSameTurn = (
entry: AgentStatusEntry | undefined,
options?: { allowInferredInterrupt?: boolean }
@@ -1856,6 +1866,10 @@ export function connectPanePty(
if (intent === 'ctrl-c' || data === '\x03') {
markTerminalBracketedPasteInterrupted(pane.terminal)
}
// Why: every delivered-input path funnels through here, so this is where a
// submit keystroke into a waiting AskUserQuestion pane becomes the
// "question answered" signal no hook will ever deliver.
questionAnsweredInference.observeSentTerminalInput(data)
}
let pendingTerminalInputWrite: Promise<void> | null = null
const setPendingTerminalInputWrite = (promise: Promise<void>): void => {
+1
View File
@@ -745,6 +745,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
onClear: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
inferQuestionAnswered: () => Promise.resolve(false),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),