fix(agent-status): show waiting for Claude AskUserQuestion, stop stale-title worktree spinners

Claude's AskUserQuestion tool is auto-allowed, so it emits a PreToolUse (not
PermissionRequest) while blocked on a human answer. normalizeClaudeEvent mapped
that to `working`, so the sidebar showed a spinner that decayed to grey while
the question sat. Map the auto-allowed AskUserQuestion PreToolUse to `waiting`
instead, mirroring the Kimi/OpenCode handling, so the row and worktree dots read
amber "Waiting for input".

Separately, a frozen braille-spinner title left by an exited agent (e.g.
"⠐ Review branch for regressions" over a shell prompt) kept classifyTitleActivity
returning `working`, spinning the worktree dot forever with 0 agents — the row
builder rejects the same unattributable title, so no agent row is shown. Gate
tabHasStatus's title-derived working/permission on the same agent attribution the
row builder uses (resolveAgentTypeFromTerminalTitle), so a title only spins the
dot when it would also show a row. Hook-driven status (hasLiveWorking/
hasPermission) is untouched.
This commit is contained in:
Jinjing
2026-07-08 17:08:41 -07:00
parent c0091c07c3
commit 00ea0fa69f
4 changed files with 148 additions and 5 deletions
@@ -93,6 +93,39 @@ describe('getWorktreeStatus', () => {
expect(status).toBe('active')
})
// Why: an exited agent can leave a frozen braille-spinner frame in the
// title forever (reproduced live: '⠐ Review branch for regressions' over a
// plain shell prompt). The row builder rejects that title as a Claude row
// (no explicit "claude" token), so the sidebar shows 0 agents — the worktree
// dot must agree instead of spinning on the unattributable title alone.
it('does not spin on a bare braille-spinner tab title that yields no agent row', () => {
const status = getWorktreeStatus(
[{ id: 'tab-1', title: '⠐ Review branch for regressions' }],
[],
livePtyMap('tab-1')
)
expect(status).toBe('active')
})
it('does not spin on a bare braille-spinner pane title that yields no agent row', () => {
const status = getWorktreeStatus([{ id: 'tab-1', title: 'bash' }], [], livePtyMap('tab-1'), {
'tab-1': { 0: '⠐ Review branch for regressions', 1: 'bash' }
})
expect(status).toBe('active')
})
it('still spins on an agent-attributable braille-spinner title', () => {
const status = getWorktreeStatus(
[{ id: 'tab-1', title: '⠹ codex fix flaky test' }],
[],
livePtyMap('tab-1')
)
expect(status).toBe('working')
})
})
describe('resolveWorktreeStatus', () => {
@@ -330,6 +363,36 @@ describe('resolveWorktreeStatus', () => {
expect(status).toBe('done')
})
it('keeps a hook-backed working agent spinning despite an unattributable stale title', () => {
// Why: hook-managed agents drive the dot via hasLiveWorking, not the title
// heuristic — the attribution gate must not regress them.
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: '⠐ Review branch for regressions' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
hasPermission: false,
hasLiveWorking: true,
hasLiveDone: false,
hasRetainedDone: false
})
expect(status).toBe('working')
})
it('resolves a stale unattributable spinner title with no hook entry to active, not working', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: '⠐ Review branch for regressions' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: false,
hasRetainedDone: false
})
expect(status).toBe('active')
})
it('falls through to active heuristic when nothing else applies', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'bash' }],
+13 -2
View File
@@ -1,3 +1,4 @@
import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id'
@@ -91,7 +92,7 @@ function tabHasStatus(
) {
continue
}
if (classifyTitleActivity(title) === status) {
if (classifyTitleActivity(title) === status && titleStatusIsAgentAttributable(title)) {
return true
}
}
@@ -103,7 +104,17 @@ function tabHasStatus(
if (agentStatusPaneIds && agentStatusPaneIds.size > 0) {
return false
}
return classifyTitleActivity(tab.title) === status
return classifyTitleActivity(tab.title) === status && titleStatusIsAgentAttributable(tab.title)
}
// Why: the title heuristic is only a fallback — hook-managed agents promote the
// worktree via hasLiveWorking/hasPermission in resolveWorktreeStatus, not here.
// A bare braille-spinner title (e.g. an exited agent's never-cleared "⠐ task"
// frame) classifies as working yet produces no sidebar agent row, spinning the
// worktree forever with "0 agents". Require the same agent attribution the row
// builder uses, so a title only spins the dot when it would also show a row.
function titleStatusIsAgentAttributable(title: string): boolean {
return resolveAgentTypeFromTerminalTitle(title) !== null
}
export function getWorktreeStatusLabel(status: WorktreeStatus): string {
+61
View File
@@ -170,6 +170,9 @@ describe('shared agent-hook-listener', () => {
)
expect(event?.payload.toolName).toBe('AskUserQuestion')
// Why: the auto-allowed AskUserQuestion PreToolUse is a human-input boundary,
// so it must read as waiting (amber attention) rather than a working spinner.
expect(event?.payload.state).toBe('waiting')
const expected = JSON.stringify(questions)
expect(event?.payload.interactivePrompt).toBe(expected)
// Why: must NOT be truncated to the 160-char toolInput preview cap.
@@ -177,6 +180,64 @@ describe('shared agent-hook-listener', () => {
expect(event?.payload.interactivePrompt!.length).toBe(expected.length)
})
it('maps Claude AskUserQuestion PreToolUse to waiting, then back to working on answer', () => {
const question = normalizeHookPayload(
state,
'claude',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'AskUserQuestion',
tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] }
}
},
'production'
)
const answered = normalizeHookPayload(
state,
'claude',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PostToolUse',
tool_name: 'AskUserQuestion',
tool_response: { selected: ['a'] }
}
},
'production'
)
expect(question?.payload).toMatchObject({
agentType: 'claude',
state: 'waiting',
toolName: 'AskUserQuestion'
})
expect(answered?.payload).toMatchObject({
agentType: 'claude',
state: 'working',
toolName: 'AskUserQuestion'
})
})
it('keeps a normal Claude PreToolUse tool call as working', () => {
const event = normalizeHookPayload(
state,
'claude',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'ls' }
}
},
'production'
)
expect(event?.payload.state).toBe('working')
expect(event?.payload.toolName).toBe('Bash')
})
it('leaves interactivePrompt undefined for a normal tool call', () => {
const event = normalizeHookPayload(
state,
+11 -3
View File
@@ -2162,13 +2162,21 @@ function normalizeClaudeEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
// Why: Claude's AskUserQuestion tool is auto-allowed, so it emits PreToolUse
// (not PermissionRequest) while blocked on a human answer — Claude posts a
// Notification instead of PermissionRequest, and Orca does not register the
// Notification hook. Treat that PreToolUse as waiting so the sidebar shows the
// amber attention state instead of a working spinner that decays to grey while
// the question sits unanswered. Mirrors normalizeKimiEvent's handling.
const isAskUserQuestion =
eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name'))
const stateName =
eventName === 'UserPromptSubmit' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure'
eventName === 'PostToolUseFailure' ||
(eventName === 'PreToolUse' && !isAskUserQuestion)
? 'working'
: eventName === 'PermissionRequest'
: eventName === 'PermissionRequest' || isAskUserQuestion
? 'waiting'
: eventName === 'Stop' || eventName === 'StopFailure'
? 'done'