mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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:
@@ -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' }],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user