fix(agent-status): clear Claude question indicator after Escape (#12064)

This commit is contained in:
Neil
2026-08-01 23:43:20 -07:00
committed by GitHub
parent 2f73775ffc
commit db69cd9387
4 changed files with 137 additions and 6 deletions
@@ -11,6 +11,7 @@ function ingestClaudeStatus(
hookEventName: 'PermissionRequest' | 'PreToolUse'
toolName: string
toolUseId: string
interactivePrompt?: string
}
): void {
server.ingestRemote(
@@ -23,7 +24,8 @@ function ingestClaudeStatus(
payload: {
state: event.state,
agentType: 'claude',
toolName: event.toolName
toolName: event.toolName,
...(event.interactivePrompt ? { interactivePrompt: event.interactivePrompt } : {})
}
},
'connection-1'
@@ -127,6 +129,72 @@ function answeredRequestFromSnapshot(
}
}
function escapeRequestFromSnapshot(
server: AgentHookServer
): Parameters<AgentHookServer['inferInterrupt']>[0] {
return {
...answeredRequestFromSnapshot(server),
intent: 'plain-escape'
}
}
describe('inferInterrupt for Claude interactive questions', () => {
it.each(['PreToolUse', 'PermissionRequest'] as const)(
'clears a %s AskUserQuestion wait after Escape',
(hookEventName) => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName,
toolName: 'AskUserQuestion',
toolUseId: 'tool-question',
interactivePrompt: '{"questions":[{"question":"Pick one"}]}'
})
expect(server.inferInterrupt(escapeRequestFromSnapshot(server))).toBe(true)
const [entry] = server.getStatusSnapshot()
expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' })
expect(entry.toolName).toBeUndefined()
expect(entry.interactivePrompt).toBeUndefined()
expect(entry.interrupted).toBeUndefined()
}
)
it('rejects Escape when the question baseline is stale', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PreToolUse',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
const request = { ...escapeRequestFromSnapshot(server), baselineUpdatedAt: 1 }
expect(server.inferInterrupt(request)).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', toolName: 'AskUserQuestion' })
])
})
it.each([
['plain-escape', 'Bash'],
['ctrl-c', 'AskUserQuestion']
] as const)('does not clear a Claude wait from %s on %s', (intent, toolName) => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PermissionRequest',
toolName,
toolUseId: 'tool-wait'
})
expect(server.inferInterrupt({ ...escapeRequestFromSnapshot(server), intent })).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', toolName })
])
})
})
describe('inferQuestionAnswered', () => {
it('clears an AskUserQuestion wait when the submit keystroke is reported', () => {
const server = new AgentHookServer()
+10 -3
View File
@@ -742,6 +742,14 @@ export class AgentHookServer {
) {
return false
}
const dismissesClaudeQuestion =
agentType === 'claude' &&
request.intent === 'plain-escape' &&
payload.state === 'waiting' &&
isAskUserQuestionTool(payload.toolName)
if (dismissesClaudeQuestion) {
return this.inferQuestionAnswered(request)
}
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
if (
payload.state !== 'working' ||
@@ -797,8 +805,7 @@ export class AgentHookServer {
return true
}
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the
* renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */
/** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
@@ -843,7 +850,7 @@ export class AgentHookServer {
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred answered question status', {
console.debug('[agent-hooks] inferred resolved question status', {
paneKey: inferred.paneKey,
state: inferred.payload.state
})
@@ -101,6 +101,51 @@ describe('agent interrupt inference', () => {
}
)
it('reports Escape while Claude is waiting on AskUserQuestion', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () =>
makeEntry({ state: 'waiting', agentType: 'claude', toolName: 'AskUserQuestion' }),
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
vi.advanceTimersByTime(500)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
tracker.dispose()
})
it.each([
['ctrl-c', 'AskUserQuestion'],
['plain-escape', 'Bash']
] as const)('does not dismiss a Claude wait from %s on %s', (intent, toolName) => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => makeEntry({ state: 'waiting', agentType: 'claude', toolName }),
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent(intent)
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
})
it('emits when the working row has no agent type', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: undefined })
@@ -7,6 +7,7 @@ import {
type AgentInterruptInferenceRequest,
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
export type AgentInterruptInference = {
@@ -56,6 +57,16 @@ function shouldIgnoreInterruptIntent(
return agentType === 'droid' && intent === 'ctrl-c'
}
function canInferInterrupt(entry: AgentStatusEntry, intent: AgentInterruptInputIntent): boolean {
return (
entry.state === 'working' ||
(intent === 'plain-escape' &&
entry.state === 'waiting' &&
entry.agentType === 'claude' &&
isAskUserQuestionTool(entry.toolName))
)
}
function isSameTurnBaseline(
left: CapturedInterruptBaseline,
right: CapturedInterruptBaseline
@@ -133,7 +144,7 @@ export function createAgentInterruptInference({
): CapturedInterruptBaseline | null => {
const agentType = entry.agentType
if (
entry.state !== 'working' ||
!canInferInterrupt(entry, intent) ||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS)
) {
return null
@@ -158,7 +169,7 @@ export function createAgentInterruptInference({
const entry = getStatusEntry()
if (
entry &&
(entry.state !== 'working' ||
(!canInferInterrupt(entry, baseline.intent) ||
entry.agentType !== baseline.agentType ||
entry.prompt !== baseline.prompt ||
entry.updatedAt !== baseline.updatedAt ||