fix(agent-status): preserve native permissions and cover palette fallbacks

This commit is contained in:
Merge Sim
2026-09-07 00:39:54 -07:00
parent 6d205ebfbe
commit 2f90e4cd75
11 changed files with 330 additions and 49 deletions
@@ -140,25 +140,48 @@ describe('palette live status', () => {
// classifies that title back as evidence. Once the pane's row aged out it stopped registering its
// identity, so the self-authored title outranked the pane's own `done` row and the palette dot
// claimed a question nobody was asking.
it('does not paint a stale self-authored action-required title as a live question', async () => {
const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
useAppStore.setState((s) => ({
tabsByWorktree: {
'wt-a': [{ ...makeTerminalTab('term-a', 'wt-a'), title: 'Codex - action required' }]
},
agentStatusByPaneKey: {
[makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', 'done', {
updatedAt: staleAt,
stateStartedAt: staleAt
it.each(['worktree', 'recent'] as const)(
'does not paint a stale self-authored title as a live %s question',
async (surface) => {
const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
useAppStore.setState((s) => ({
tabsByWorktree: {
'wt-a': [{ ...makeTerminalTab('term-a', 'wt-a'), title: 'Codex - action required' }]
},
agentStatusByPaneKey: {
[makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', 'done', {
updatedAt: staleAt,
stateStartedAt: staleAt
})
},
agentStatusEpoch: s.agentStatusEpoch + 1
}))
if (surface === 'worktree') {
await render()
} else {
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'recent',
worktreeId: 'wt-a',
unifiedTabId: null,
terminalTab: { id: 'term-a', title: 'Codex - action required' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
},
agentStatusEpoch: s.agentStatusEpoch + 1
}))
expect(testContainer.querySelector('[data-fallback]')).not.toBeNull()
}
await render()
expect(dotLabels()).not.toContain('Needs permission')
})
expect(dotLabels()).not.toContain('Needs permission')
}
)
it('updates a worktree dot when the agent transitions', async () => {
setAgentState('working')
@@ -172,6 +195,53 @@ describe('palette live status', () => {
expect(dotLabels()).toEqual(['Needs permission'])
})
it('attributes stale permission titles to their split pane without hiding a live sibling', async () => {
const otherLeaf = '22222222-2222-4222-8222-222222222222'
const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
setAgentState('done', { updatedAt: staleAt, stateStartedAt: staleAt })
useAppStore.setState({
terminalLayoutsByTabId: {
'term-a': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF },
second: { type: 'leaf', leafId: otherLeaf }
},
activeLeafId: otherLeaf,
expandedLeafId: null
}
},
runtimePaneTitlesByTabId: {
'term-a': { 1: 'Codex - action required', 2: 'shell' }
}
})
await render()
expect(dotLabels()).toEqual(['Active'])
await act(async () => {
useAppStore.setState({
runtimePaneTitlesByTabId: { 'term-a': { 2: 'Codex - action required' } }
})
})
expect(dotLabels()).toEqual(['Needs permission'])
await act(async () => {
useAppStore.setState({
runtimePaneTitlesByTabId: {
'term-a': { 1: 'Codex - action required', 2: '⠹ codex working' }
}
})
})
expect(dotLabels()).toEqual(['Working'])
await act(async () => {
setAgentState('blocked')
})
expect(dotLabels()).toEqual(['Needs permission'])
})
it('shows monitoring when a covered pane retains a working title', async () => {
setAgentState('working', { workingMode: 'monitoring' })
useAppStore.setState({
@@ -140,12 +140,7 @@ export function PaletteLiveStatusProvider({
)
}
/**
* Split each tab's explicit rows into fresh and stale pane ids. Fresh rows suppress every title
* heuristic; stale rows suppress `permission` only, because Orca writes its own one-shot
* "<Agent> - action required" title and never refreshes it — a stale row that registered nothing
* let that string outlive the row and assert a question the agent had stopped asking.
*/
/** Fresh rows suppress all title heuristics; stale rows suppress generated permission labels. */
function buildLiveAgentStatusPaneIdsByTabId(
entriesByTabId: ReadonlyMap<string, readonly AgentStatusEntry[]>,
now: number
@@ -219,7 +214,8 @@ export function PaletteWorktreeStatusDot({
{
liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id),
agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId,
stalePaneIdsByTabId: live.stalePaneIdsByTabId
stalePaneIdsByTabId: live.stalePaneIdsByTabId,
terminalLayoutsByTabId: live.paneSources.terminalLayoutsByTabId
}
)
return (
@@ -3,6 +3,7 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isSyntheticAgentPermissionTitle } from '../../../../shared/synthetic-agent-title'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import type { AgentStatus } from '../../../../shared/agent-detection'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
@@ -300,8 +301,14 @@ export function collectTabPaneInputs(
const hasLivePty = tabHasLivePty(sources.ptyIdsByTabId, tab.id)
// Why: leaves covered by a hook entry skip the title fallback so we don't double-count them.
const hookLeafIds = new Set<string>()
// Stale hooks still suppress one-shot permission titles, matching worktree and tab status dots.
const permissionHookLeafIds = new Set<string>()
for (const entry of sources.entriesByTabId.get(tab.id) ?? []) {
panes.push({ kind: 'hook', entry, hasLivePty })
const leafId = leafIdFromPaneKey(entry.paneKey)
if (leafId !== null) {
permissionHookLeafIds.add(leafId)
}
// Why: restored rows own their co-restored title without asserting live state.
if (
!entry.restoredUnconfirmed &&
@@ -309,7 +316,6 @@ export function collectTabPaneInputs(
) {
continue
}
const leafId = leafIdFromPaneKey(entry.paneKey)
if (leafId !== null) {
hookLeafIds.add(leafId)
}
@@ -322,7 +328,10 @@ export function collectTabPaneInputs(
const paneTitles = sources.runtimePaneTitlesByTabId[tab.id]
if (!paneTitles || Object.keys(paneTitles).length === 0) {
if (hookLeafIds.size === 0) {
const coveredLeafIds = isSyntheticAgentPermissionTitle(tab.title)
? permissionHookLeafIds
: hookLeafIds
if (coveredLeafIds.size === 0) {
// Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title.
panes.push({
kind: 'title',
@@ -337,10 +346,13 @@ export function collectTabPaneInputs(
const tabLayout = sources.terminalLayoutsByTabId?.[tab.id]
const paneTitleEntries = Object.entries(paneTitles)
for (const [runtimePaneId, title] of paneTitleEntries) {
const coveredLeafIds = isSyntheticAgentPermissionTitle(title)
? permissionHookLeafIds
: hookLeafIds
const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId)
const hasSingleUnmappedHook =
leafId === null && hookLeafIds.size === 1 && paneTitleEntries.length === 1
if ((leafId !== null && hookLeafIds.has(leafId)) || hasSingleUnmappedHook) {
leafId === null && coveredLeafIds.size === 1 && paneTitleEntries.length === 1
if ((leafId !== null && coveredLeafIds.has(leafId)) || hasSingleUnmappedHook) {
continue
}
panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt })
@@ -21,12 +21,7 @@ export type WorktreeAgentActivitySummary = {
hasLiveDone: boolean
hasRetainedDone: boolean
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
/**
* Panes whose explicit row exists but has gone stale. A stale row is not authority for a status,
* but it still proves the pane HAS an agent — which must keep Orca's own one-shot
* "<Agent> - action required" title from re-asserting a question the agent stopped asking.
* Suppresses `permission` titles only; a working spinner re-renders, so it stays live evidence.
*/
/** Stale rows suppress generated permission labels while preserving native title fallback. */
stalePaneIdsByTabId: Record<string, ReadonlySet<string>>
}
@@ -86,6 +86,24 @@ describe('resolveTerminalTabActivityStatus', () => {
).toBe('working')
})
it.each(['tab', 'pane'] as const)(
'keeps native permission %s titles after hook freshness expires',
(surface) => {
const stale = entry(FIRST_LEAF_ID, 'working', {
agentType: 'gemini',
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1
})
expect(
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: '✋ Gemini CLI' },
agentStatusByPaneKey: { [stale.paneKey]: stale },
ptyIdsByTabId: LIVE_PTY,
runtimePaneTitlesByTabId: surface === 'pane' ? { [TAB_ID]: { 1: '✋ Gemini CLI' } } : {}
})
).toBe('permission')
}
)
it('reports monitoring without hiding active or actionable siblings', () => {
const monitoring = entry(FIRST_LEAF_ID, 'working', { workingMode: 'monitoring' })
const working = entry(SECOND_LEAF_ID, 'working')
@@ -23,7 +23,7 @@ type TerminalTabActivityFlags = {
hasInterrupted: boolean
hasLiveDone: boolean
paneIds: Set<string>
/** Panes whose row went stale; suppress `permission` titles only. */
/** Panes whose row went stale; suppress generated permission labels only. */
stalePaneIds: Set<string>
}
@@ -71,10 +71,7 @@ function getTerminalTabActivityFlags(
// Why: stale hook entries (>30m) are not authority; a slept/abandoned pane
// must not keep a tab spinning. Same freshness gate as the sidebar.
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
// Why: the row loses authority but the pane keeps its identity, which suppresses
// `permission` titles — Orca writes its own one-shot "<Agent> - action required" title and
// never refreshes it, so a stale row let that string assert a question nobody was asking.
// Working titles still fall back here: a spinner re-renders, so it stays live evidence.
// Stale identity suppresses Orca's one-shot permission label without suppressing native titles.
getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId).stalePaneIds.add(
identity.paneId
)
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { useAppStore } from '@/store'
import { createTestStore } from '@/store/slices/store-test-helpers'
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { buildWindowApi } from '../ipc-events-agent-status-window-test-fixtures'
import type { AgentStatusSetData } from '../ipc-events-agent-status-store-test-fixtures'
import { resolvePaneKey, shouldApplyResolvedAgentTerminalTitleToTab } from './agent-status-routing'
vi.mock('../agent-hook-completion-notifications', () => ({
observeAgentHookCompletionForNotification: vi.fn(),
syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn()
}))
const TAB_ID = 'tab-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const WORKTREE_ID = 'repo-1::/wt-1'
@@ -96,3 +105,102 @@ describe('hook-driven tab title writes', () => {
).toBe(false)
})
})
describe('hook-driven tab title IPC integration', () => {
afterEach(() => {
vi.doUnmock('../../store')
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it.each([
{ mode: 'live', states: ['done'], title: 'Codex - action required', expected: 'Codex ready' },
{
mode: 'snapshot',
states: ['waiting', 'done'],
title: 'Codex ready',
expected: 'Codex ready'
},
{
mode: 'snapshot',
states: ['done', 'waiting'],
title: 'Codex ready',
expected: 'Codex - action required'
},
{
mode: 'inactive-pane',
states: ['done'],
title: 'Codex - action required',
expected: 'Codex - action required'
}
] as const)(
'applies $mode $states against the tab title slot',
async ({ mode, states, title, expected }) => {
vi.resetModules()
const store = createTestStore()
const seeded = storeWithDivergedTitleSlots({ tabTitle: title, paneSlotTitle: 'Codex ready' })
const otherLeaf = '22222222-2222-4222-8222-222222222222'
if (mode === 'inactive-pane') {
seeded.terminalLayoutsByTabId[TAB_ID] = {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: otherLeaf }
},
activeLeafId: otherLeaf,
expandedLeafId: null,
titlesByLeafId: { [LEAF_ID]: 'Codex ready', [otherLeaf]: title }
}
}
store.setState({ ...seeded, workspaceSessionReady: true, activeWorktreeId: null })
const events = states.map((state, index): AgentStatusIpcPayload & AgentStatusSetData => ({
paneKey: PANE_KEY,
worktreeId: WORKTREE_ID,
connectionId: null,
state,
agentType: 'codex',
prompt: 'Title clearing test',
receivedAt: Date.now() + index,
stateStartedAt: Date.now() + index
}))
let onSet: (payload: AgentStatusSetData) => void = () => {
throw new Error('listener missing')
}
vi.doMock('../../store', () => ({ useAppStore: store }))
vi.stubGlobal(
'window',
buildWindowApi({
getSnapshot: async () => (mode === 'snapshot' ? events : []),
onSet: (callback) => {
onSet = callback
return () => {}
}
})
)
const { registerAgentStatusIpcBridge } = await import('./agent-status-ipc-bridge')
const updateTitle = vi.spyOn(store.getState(), 'updateTabTitle')
const updateTitles = vi.spyOn(store.getState(), 'updateTabTitles')
const unsubs: (() => void)[] = []
const bridge = registerAgentStatusIpcBridge(unsubs)
try {
if (mode !== 'snapshot') {
onSet(events[0])
}
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.state).toBe(states.at(-1))
})
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].title).toBe(expected)
expect(store.getState().agentStatusByPaneKey[PANE_KEY].terminalTitle).toBe(
states.at(-1) === 'done' ? 'Codex ready' : 'Codex - action required'
)
expect(updateTitle).toHaveBeenCalledTimes(mode === 'live' ? 1 : 0)
expect(updateTitles).toHaveBeenCalledTimes(mode === 'snapshot' ? 1 : 0)
} finally {
bridge.disposeAsyncState()
bridge.unsubscribeStore()
unsubs.forEach((unsubscribe) => unsubscribe())
}
}
)
})
@@ -5,7 +5,11 @@ import {
type RecentWorkspaceTabRow
} from './recent-workspace-tab-rows'
import type { TabPaneInputSources } from '@/components/sidebar/smart-attention'
import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentStatusState
} from '../../../shared/agent-status-types'
const NOW = 1_700_000_000_000
const LEAF_ID = '11111111-2222-4333-8444-555555555555'
@@ -106,6 +110,54 @@ describe('orderRecentWorkspaceTabs', () => {
})
describe('resolveRecentWorkspaceTabStatus', () => {
it.each(['tab', 'pane'] as const)(
'suppresses a stale done pane permission %s title',
(surface) => {
const title = 'Codex - action required'
const stale = entry('stale', 'done', NOW - AGENT_STATUS_STALE_AFTER_MS - 1)
const paneSources = sources([stale], {
ptyIdsByTabId: { stale: ['pty-1'] },
runtimePaneTitlesByTabId: surface === 'pane' ? { stale: { 1: title } } : {}
})
expect(
resolveRecentWorkspaceTabStatus(
row('stale', { terminalTab: { id: 'stale', title } }),
paneSources,
NOW
)
).toBe('active')
stale.updatedAt = NOW
stale.state = 'blocked'
expect(resolveRecentWorkspaceTabStatus(row('stale'), paneSources, NOW)).toBe('permission')
}
)
it('keeps stale-pane spinner fallback and permission on an uncovered split sibling', () => {
const stale = entry('split', 'done', NOW - AGENT_STATUS_STALE_AFTER_MS - 1)
const paneSources = sources([stale], {
ptyIdsByTabId: { split: ['pty-1', 'pty-2'] },
terminalLayoutsByTabId: {
split: {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' }
},
activeLeafId: LEAF_ID,
expandedLeafId: null
}
},
runtimePaneTitlesByTabId: { split: { 1: 'Codex - action required', 2: 'zsh' } }
})
expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('active')
paneSources.runtimePaneTitlesByTabId.split = { 1: '⠹ codex working', 2: 'zsh' }
expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('working')
paneSources.runtimePaneTitlesByTabId.split = { 2: 'Codex - action required' }
expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('permission')
})
it('surfaces an interrupted outcome without promoting its sort class', () => {
const interrupted = entry('interrupted', 'done', NOW - 1_000, { interrupted: true })
+13 -8
View File
@@ -3,6 +3,7 @@ import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id'
import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core'
import { isSyntheticAgentPermissionTitle } from '../../../shared/synthetic-agent-title'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
@@ -23,12 +24,7 @@ export type WorktreeStatus =
type WorktreeStatusHeuristicOptions = {
liveAgentStatus?: LiveAgentWorktreeStatus
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
/**
* Panes whose explicit row has gone stale. They suppress `permission` titles only: Orca writes
* its own "<Agent> - action required" title once per blocked/waiting hook and never refreshes it,
* so a stale row leaves that one-shot string asserting a question the agent stopped asking. A
* working title keeps its stale-row fallback — a spinner re-renders, so it is live evidence.
*/
/** Stale rows suppress Orca's generated permission labels; native title fallback stays live. */
stalePaneIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
@@ -80,13 +76,18 @@ function tabHasStatus(
status: 'permission' | 'working',
options: WorktreeStatusHeuristicOptions
): boolean {
const agentStatusPaneIds = suppressingPaneIds(tab.id, status, options)
const freshPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id]
const permissionPaneIds = suppressingPaneIds(tab.id, status, options)
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
const tabLayoutRoot =
options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root
const paneTitleEntries = Object.entries(paneTitles)
for (const [runtimePaneId, title] of paneTitleEntries) {
const agentStatusPaneIds =
status === 'permission' && isSyntheticAgentPermissionTitle(title)
? permissionPaneIds
: freshPaneIds
const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId)
// Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner.
const hasSingleUnmappedAgentStatusPane =
@@ -108,6 +109,10 @@ function tabHasStatus(
return false
}
// Why: a tab title can't identify its pane; once an agent row owns one, prefer the row over a completed pane's stale "working" title.
const agentStatusPaneIds =
status === 'permission' && isSyntheticAgentPermissionTitle(tab.title)
? permissionPaneIds
: freshPaneIds
if (agentStatusPaneIds && agentStatusPaneIds.size > 0) {
return false
}
@@ -119,7 +124,7 @@ function tabHasStatus(
/**
* Pane ids whose title must not drive `status` for this tab. Fresh rows suppress every heuristic;
* stale rows suppress `permission` only (see `stalePaneIdsByTabId`). Returns the fresh set itself
* stale rows suppress synthetic permission labels only. Returns the fresh set itself
* when there is nothing to add, so the common path allocates nothing.
*/
function suppressingPaneIds(
+18
View File
@@ -1,10 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
getSyntheticAgentTerminalTitle,
isSyntheticAgentPermissionTitle,
shouldDriveSyntheticAgentTitleFromHook
} from './synthetic-agent-title'
describe('synthetic agent titles', () => {
it.each(['Codex - action required', ' Pi - action required ', 'OMP - action required'])(
'recognizes the generated permission label %s',
(title) => {
expect(isSyntheticAgentPermissionTitle(title)).toBe(true)
}
)
it.each([
'✋ Gemini CLI',
'π ! approve command',
'OpenCode - action required',
'Codex ready',
'Codex - action required for deployment'
])('keeps native and contextual titles outside generated permission suppression: %s', (title) => {
expect(isSyntheticAgentPermissionTitle(title)).toBe(false)
})
it('provides terminal-state titles for Codex hook completion', () => {
expect(getSyntheticAgentTerminalTitle('codex', 'done')).toBe('Codex ready')
expect(getSyntheticAgentTerminalTitle('codex', 'waiting')).toBe('Codex - action required')
+10
View File
@@ -78,6 +78,16 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
}
}
const SYNTHETIC_PERMISSION_TITLES: ReadonlySet<string> = new Set(
Object.values(SYNTHETIC_AGENT_TITLE_PROFILES)
.filter((profile) => profile.synthesizeTerminalTitle !== false)
.map((profile) => profile.permissionLabel.toLowerCase())
)
export function isSyntheticAgentPermissionTitle(title: string): boolean {
return SYNTHETIC_PERMISSION_TITLES.has(title.trim().toLowerCase())
}
export function getSyntheticAgentTitleProfile(
agentType: AgentType | null | undefined
): SyntheticAgentTitleProfile | null {