mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(agent-status): clear Claude question indicator after Escape (#12064)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user