fix(agent-status): stop a stale self-authored agent title from faking a pending question (#19237)

* fix(agent-status): stop a stale self-authored agent title from faking a pending question

A workspace card could show the amber "agent is asking you something" icon while
every pane sat idle and its only agent row read `done`.

Orca injects its own `<Agent> - action required` OSC title when a hook reports
blocked/waiting, then classifies that same title back as evidence. Two gaps let
that one-shot string outlive the state it described:

- The pane-id sets that suppress the title heuristic were built only from FRESH
  rows, so once a row aged past AGENT_STATUS_STALE_AFTER_MS the pane stopped
  suppressing its own title and `permission` — which outranks `done` — decided
  the indicator. Pane identity is not a liveness fact, so it is now tracked
  separately and never expires. Stale rows suppress `permission` only; a working
  spinner re-renders, so its stale-row fallback is preserved.

- The hook-driven tab-title write compared the resolved title against the pane's
  layout slot (`titlesByLeafId`, which only a mounted pane updates) while writing
  `tab.title`. Once those slots diverged, `done` resolved to a title equal to the
  pane slot, the no-op guard skipped the write, and the tab kept the stale label.
  The guard now compares against the slot it actually overwrites.

All three status surfaces read the same suppression inputs, so all three showed
it: the workspace card, the terminal tab glyph, and the cmd-J palette dot.

Tests: each fix has a regression test that fails without it (the sidebar, tab-bar
and palette tests all go red from a single ablation of the stale-set lookup).

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

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-07 09:40:32 -07:00
committed by GitHub
co-authored by Merge Sim
parent 0252fe5c36
commit da4da8e60a
17 changed files with 656 additions and 46 deletions
@@ -6,7 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AppState } from '@/store/types'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentStatusState
} from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
@@ -132,6 +136,53 @@ describe('palette live status', () => {
})
}
// Why: Orca injects its own "<Agent> - action required" OSC title on a blocked/waiting hook and
// 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.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>
)
})
expect(testContainer.querySelector('[data-fallback]')).not.toBeNull()
}
expect(dotLabels()).not.toContain('Needs permission')
}
)
it('updates a worktree dot when the agent transitions', async () => {
setAgentState('working')
await render()
@@ -144,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({
@@ -41,6 +41,7 @@ import { useNow } from '@/hooks/use-now'
type PaletteLiveStatus = {
liveAgentStatusByWorktreeId: ReadonlyMap<string, LiveAgentWorktreeStatus>
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
stalePaneIdsByTabId: Record<string, ReadonlySet<string>>
paneSources: TabPaneInputSources
tabsByWorktree: Record<string, TerminalTab[]>
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
@@ -98,13 +99,15 @@ export function PaletteLiveStatusProvider({
agentStatusByPaneKey,
migrationUnsupportedByPtyId
)
const livePaneIds = buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now)
return {
liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId(
agentStatusByPaneKey,
tabsByWorktree,
now
),
agentStatusPaneIdsByTabId: buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now),
agentStatusPaneIdsByTabId: livePaneIds.paneIdsByTabId,
stalePaneIdsByTabId: livePaneIds.stalePaneIdsByTabId,
paneSources: {
entriesByTabId,
ptyIdsByTabId,
@@ -137,30 +140,41 @@ export function PaletteLiveStatusProvider({
)
}
/** Fresh rows suppress all title heuristics; stale rows suppress generated permission labels. */
function buildLiveAgentStatusPaneIdsByTabId(
entriesByTabId: ReadonlyMap<string, readonly AgentStatusEntry[]>,
now: number
): Record<string, ReadonlySet<string>> {
): {
paneIdsByTabId: Record<string, ReadonlySet<string>>
stalePaneIdsByTabId: Record<string, ReadonlySet<string>>
} {
const paneIdsByTabId: Record<string, ReadonlySet<string>> = {}
const stalePaneIdsByTabId: Record<string, ReadonlySet<string>> = {}
for (const [tabId, entries] of entriesByTabId) {
const paneIds = new Set<string>()
const stalePaneIds = new Set<string>()
for (const entry of entries) {
const paneId = parsePaneKey(entry.paneKey)?.leafId
if (!paneId) {
continue
}
if (
entry.restoredUnconfirmed !== true &&
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
stalePaneIds.add(paneId)
continue
}
const paneId = parsePaneKey(entry.paneKey)?.leafId
if (paneId) {
paneIds.add(paneId)
}
paneIds.add(paneId)
}
if (paneIds.size > 0) {
paneIdsByTabId[tabId] = paneIds
}
if (stalePaneIds.size > 0) {
stalePaneIdsByTabId[tabId] = stalePaneIds
}
}
return paneIdsByTabId
return { paneIdsByTabId, stalePaneIdsByTabId }
}
const EMPTY_LIVE_INPUTS = Object.freeze({
@@ -199,7 +213,9 @@ export function PaletteWorktreeStatusDot({
live.paneSources.runtimePaneTitlesByTabId,
{
liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id),
agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId
agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId,
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 })
@@ -29,7 +29,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
hasInterrupted,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
agentStatusPaneIdsByTabId,
stalePaneIdsByTabId
} = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
// Why: compact and detailed cards need the same status-dot semantics:
@@ -43,6 +44,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
ptyIdsByTabId: ptyIdsForWorktree,
runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
agentStatusPaneIdsByTabId,
stalePaneIdsByTabId,
terminalLayoutRootsByTabId,
hasPermission,
hasLiveWorking,
@@ -57,6 +59,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
ptyIdsForWorktree,
runtimePaneTitlesForWorktree,
agentStatusPaneIdsByTabId,
stalePaneIdsByTabId,
terminalLayoutRootsByTabId,
hasPermission,
hasLiveWorking,
@@ -37,7 +37,8 @@ export function selectWorktreeActivityStatuses(
hasInterrupted,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
agentStatusPaneIdsByTabId,
stalePaneIdsByTabId
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
statuses.set(
worktreeId,
@@ -47,6 +48,7 @@ export function selectWorktreeActivityStatuses(
ptyIdsByTabId: selectLivePtyIdsForWorktree(statusInputs, worktreeId),
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId),
agentStatusPaneIdsByTabId,
stalePaneIdsByTabId,
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId),
hasPermission,
hasLiveWorking,
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { shallow } from 'zustand/shallow'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolveWorktreeStatus } from '@/lib/worktree-status'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
selectWorktreeAgentActivitySummary,
@@ -440,4 +444,63 @@ describe('selectWorktreeAgentActivitySummary', () => {
const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')
expect(summary.agentStatusPaneIdsByTabId['tab-parent']).toEqual(new Set([LEAF_ID]))
})
// Why: Orca injects its own "<Agent> - action required" OSC title on a blocked/waiting hook,
// then classifies that title back as evidence. If a pane stopped registering its identity once
// its row aged out, that self-authored title outranked the pane's own `done` row and pinned the
// workspace card to the question icon with no agent asking anything.
it('records a stale entry pane id separately so permission titles stay suppressed', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const entry = makeAgentStatusEntry({ paneKey, state: 'done', worktreeId: 'repo::/wt-1' })
vi.spyOn(Date, 'now').mockReturnValue(entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS + 1)
const state: AgentActivityInput = {
tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] },
agentStatusEpoch: 0,
agentStatusByPaneKey: { [paneKey]: entry },
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
retainedAgentsByPaneKey: {}
}
const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')
expect(summary.stalePaneIdsByTabId['tab-1']).toEqual(new Set([LEAF_ID]))
// Staleness still ends the row's authority: no fresh pane id, no liveness flag.
expect(summary.agentStatusPaneIdsByTabId['tab-1']).toBeUndefined()
expect(summary.hasLiveDone).toBe(false)
})
// Reproduces the reported card: a Codex pane parked at its composer, its only agent row `done`
// and ~2h old, and the workspace still painting the amber question icon. `permission` outranks
// `hasLiveDone` in resolveWorktreeStatus, so the pane's stale self-authored title decided the
// card. With no fresh evidence the honest answer is `active`, never a question nobody asked.
it('does not paint a stale self-authored action-required title as a live question', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const entry = makeAgentStatusEntry({ paneKey, state: 'done', worktreeId: 'repo::/wt-1' })
vi.spyOn(Date, 'now').mockReturnValue(entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS + 1)
const tab = { ...makeTab('tab-1', 'repo::/wt-1'), title: 'Codex - action required' }
const state: AgentActivityInput = {
tabsByWorktree: { 'repo::/wt-1': [tab] },
agentStatusEpoch: 0,
agentStatusByPaneKey: { [paneKey]: entry },
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
retainedAgentsByPaneKey: {}
}
const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')
const status = resolveWorktreeStatus({
tabs: [tab],
browserTabs: [],
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
agentStatusPaneIdsByTabId: summary.agentStatusPaneIdsByTabId,
stalePaneIdsByTabId: summary.stalePaneIdsByTabId,
hasPermission: summary.hasPermission,
hasLiveWorking: summary.hasLiveWorking,
hasLiveDone: summary.hasLiveDone,
hasRetainedDone: summary.hasRetainedDone
})
expect(status).toBe('active')
})
})
@@ -21,6 +21,8 @@ export type WorktreeAgentActivitySummary = {
hasLiveDone: boolean
hasRetainedDone: boolean
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
/** Stale rows suppress generated permission labels while preserving native title fallback. */
stalePaneIdsByTabId: Record<string, ReadonlySet<string>>
}
const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record<string, ReadonlySet<string>> = {}
@@ -32,7 +34,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasInterrupted: false,
hasLiveDone: false,
hasRetainedDone: false,
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID,
stalePaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
}
type AgentActivityTabsByWorktree = Record<string, readonly { id: string }[]>
@@ -121,6 +124,10 @@ function getWorktreeAgentActivitySummaries(
continue
}
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
// Why: staleness ends this row's authority but not the pane's identity — see
// `stalePaneIdsByTabId`. Dropping both let Orca's self-authored permission title outlive
// the row it came from and pin the card to a question nobody was asking.
addStalePaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
continue
}
addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
@@ -189,7 +196,8 @@ function summariesEqual(
agentStatusPaneIdsByTabIdEqual(
previous.agentStatusPaneIdsByTabId,
next.agentStatusPaneIdsByTabId
)
) &&
agentStatusPaneIdsByTabIdEqual(previous.stalePaneIdsByTabId, next.stalePaneIdsByTabId)
)
}
@@ -244,15 +252,31 @@ function addAgentStatusPaneId(
tabId: string,
paneId: string
): void {
if (summary.agentStatusPaneIdsByTabId === EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID) {
summary.agentStatusPaneIdsByTabId = {}
}
let paneIds = summary.agentStatusPaneIdsByTabId[tabId] as Set<string> | undefined
summary.agentStatusPaneIdsByTabId = withPaneId(summary.agentStatusPaneIdsByTabId, tabId, paneId)
}
function addStalePaneId(
summary: WorktreeAgentActivitySummary,
tabId: string,
paneId: string
): void {
summary.stalePaneIdsByTabId = withPaneId(summary.stalePaneIdsByTabId, tabId, paneId)
}
function withPaneId(
byTabId: Record<string, ReadonlySet<string>>,
tabId: string,
paneId: string
): Record<string, ReadonlySet<string>> {
// Why: the shared empty record is the frozen default for every summary; copy on first write.
const next = byTabId === EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID ? {} : byTabId
let paneIds = next[tabId] as Set<string> | undefined
if (!paneIds) {
paneIds = new Set<string>()
summary.agentStatusPaneIdsByTabId[tabId] = paneIds
next[tabId] = paneIds
}
paneIds.add(paneId)
return next
}
function worktreeIdForPaneKey(
@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
hasUnreadAgentCompletionForTerminalTab,
@@ -54,6 +57,24 @@ afterEach(() => {
})
describe('resolveTerminalTabActivityStatus', () => {
// Why: Orca injects its own "<Agent> - action required" OSC title on a blocked/waiting hook and
// classifies that title back as evidence. Once the pane's row aged past the freshness window it
// stopped registering its identity, so the self-authored title outranked the pane's own `done`
// row and the tab glyph claimed a question nobody was asking.
it('does not paint a stale self-authored action-required title as a live question', () => {
const done = entry(FIRST_LEAF_ID, 'done', {
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1
})
expect(
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'Codex - action required' },
agentStatusByPaneKey: { [done.paneKey]: done },
ptyIdsByTabId: LIVE_PTY
})
).toBe('active')
})
it('reports a fresh hook working state', () => {
const working = entry(FIRST_LEAF_ID, 'working')
expect(
@@ -65,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,6 +23,8 @@ type TerminalTabActivityFlags = {
hasInterrupted: boolean
hasLiveDone: boolean
paneIds: Set<string>
/** Panes whose row went stale; suppress generated permission labels only. */
stalePaneIds: Set<string>
}
type FlagsCache = {
@@ -69,6 +71,10 @@ 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)) {
// Stale identity suppresses Orca's one-shot permission label without suppressing native titles.
getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId).stalePaneIds.add(
identity.paneId
)
continue
}
@@ -106,7 +112,8 @@ function getOrCreateTerminalTabActivityFlags(
hasLiveMonitoring: false,
hasInterrupted: false,
hasLiveDone: false,
paneIds: new Set()
paneIds: new Set(),
stalePaneIds: new Set()
}
flagsByTabId.set(tabId, flags)
}
@@ -162,6 +169,7 @@ export function resolveTerminalTabActivityStatus({
ptyIdsByTabId: ptyIdsByTabId ?? {},
runtimePaneTitlesByTabId: runtimePaneTitlesByTabId ?? {},
agentStatusPaneIdsByTabId: { [tab.id]: flags?.paneIds ?? EMPTY_PANE_IDS },
stalePaneIdsByTabId: { [tab.id]: flags?.stalePaneIds ?? EMPTY_PANE_IDS },
terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined,
hasPermission: flags?.hasPermission ?? false,
hasLiveWorking: flags?.hasLiveWorking ?? false,
@@ -69,7 +69,8 @@ export function createAgentStatusEventApplicator(args: {
repoConnectionId,
repoConnectionResolved,
owningWorktreeId,
titleUsesTabTitle
titleUsesTabTitle,
tabTitle
} = resolvePaneKeyFromRoutingIndex(routingIndex, paneKey)
const projectedTitles =
titleUsesTabTitle && ownerTabId
@@ -79,6 +80,7 @@ export function createAgentStatusEventApplicator(args: {
title = projectedTitles.title
identityTitle = projectedTitles.identityTitle
}
tabTitle = options?.batch?.tabTitlesByTabId.get(ownerTabId ?? '') ?? tabTitle
if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
const fallbackOwnership = resolveWorktreeConnectionFromRoutingIndex(
routingIndex,
@@ -266,14 +268,13 @@ export function createAgentStatusEventApplicator(args: {
options.batch.notificationEffects.push(applyPostCommitNotification)
if (
terminalTitle &&
shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle)
shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, tabTitle, terminalTitle)
) {
const tabId = parsePaneKey(paneKey)?.tabId
if (tabId) {
options.batch.tabTitlesByTabId.set(tabId, terminalTitle)
if (ownerTabId) {
options.batch.tabTitlesByTabId.set(ownerTabId, terminalTitle)
if (titleUsesTabTitle) {
const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle)
options.batch.projectedTitlesByTabId.set(tabId, {
options.batch.projectedTitlesByTabId.set(ownerTabId, {
title: titleChanges ? terminalTitle : title,
identityTitle: titleChanges ? terminalTitle : identityTitle
})
@@ -289,7 +290,7 @@ export function createAgentStatusEventApplicator(args: {
update.routing,
update.metadata
)
applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle)
applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, tabTitle, terminalTitle)
applyPostCommitNotification()
}
return 'applied'
@@ -12,6 +12,7 @@ type AgentStatusPaneResolution = {
repoConnectionResolved: boolean
owningWorktreeId: string | undefined
titleUsesTabTitle: boolean
tabTitle: string | undefined
}
type AgentStatusWorktreeConnectionResolution = {
@@ -186,7 +187,8 @@ export function resolvePaneKeyFromRoutingIndex(
repoConnectionId: null,
repoConnectionResolved: false,
owningWorktreeId: undefined,
titleUsesTabTitle: false
titleUsesTabTitle: false,
tabTitle: undefined
}
}
const { tabId, leafId } = parsed
@@ -199,7 +201,8 @@ export function resolvePaneKeyFromRoutingIndex(
repoConnectionId: null,
repoConnectionResolved: false,
owningWorktreeId: undefined,
titleUsesTabTitle: false
titleUsesTabTitle: false,
tabTitle: undefined
}
}
const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId)
@@ -219,7 +222,8 @@ export function resolvePaneKeyFromRoutingIndex(
repoConnectionId: connection.repoConnectionId,
repoConnectionResolved: connection.repoConnectionResolved,
owningWorktreeId: tab.owningWorktreeId,
titleUsesTabTitle: false
titleUsesTabTitle: false,
tabTitle: undefined
}
}
}
@@ -233,6 +237,7 @@ export function resolvePaneKeyFromRoutingIndex(
repoConnectionId: connection.repoConnectionId,
repoConnectionResolved: connection.repoConnectionResolved,
owningWorktreeId: tab.owningWorktreeId,
titleUsesTabTitle: paneTitle === undefined
titleUsesTabTitle: paneTitle === undefined,
tabTitle: tab.title
}
}
@@ -40,12 +40,12 @@ export function tryMakePaneKey(tabId: string, leafId: string): string | null {
export function applyResolvedAgentTerminalTitleToTab(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string,
previousTitle: string | undefined,
currentTabTitle: string | undefined,
nextTitle: string | undefined
): void {
if (
!nextTitle ||
!shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle)
!shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, currentTabTitle, nextTitle)
) {
return
}
@@ -57,13 +57,22 @@ export function applyResolvedAgentTerminalTitleToTab(
store.updateTabTitle(parsed.tabId, nextTitle)
}
/**
* `currentTabTitle` must be the TAB record's title, not the pane's layout slot. This path writes
* `tab.title` and nothing else, so comparing against `titlesByLeafId` — which only a mounted pane
* updates — skipped the write whenever the two slots had diverged, stranding a self-authored
* "<Agent> - action required" label on the tab after the agent had already reported done.
*
* Inside a batch, pass the staged `tabTitlesByTabId` value when one exists: the batch flushes tab
* titles at the end, so an earlier event's staged write is what a later event actually overwrites.
*/
export function shouldApplyResolvedAgentTerminalTitleToTab(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string,
previousTitle: string | undefined,
currentTabTitle: string | undefined,
nextTitle: string | undefined
): boolean {
if (!nextTitle || nextTitle === previousTitle) {
if (!nextTitle || nextTitle === currentTabTitle) {
return false
}
const parsed = parsePaneKey(paneKey)
@@ -92,6 +101,8 @@ export function resolvePaneKey(
repoConnectionResolved: boolean
owningWorktreeId: string | undefined
titleUsesTabTitle: boolean
/** The tab record's own title, which is the slot the hook-driven tab write actually overwrites. */
tabTitle: string | undefined
} {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
@@ -102,7 +113,8 @@ export function resolvePaneKey(
repoConnectionId: null,
repoConnectionResolved: false,
owningWorktreeId: undefined,
titleUsesTabTitle: false
titleUsesTabTitle: false,
tabTitle: undefined
}
}
const { tabId, leafId } = parsed
@@ -149,7 +161,8 @@ export function resolvePaneKey(
repoConnectionId,
repoConnectionResolved,
owningWorktreeId,
titleUsesTabTitle: false
titleUsesTabTitle: false,
tabTitle: undefined
}
}
// Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed.
@@ -162,7 +175,8 @@ export function resolvePaneKey(
repoConnectionId,
repoConnectionResolved,
owningWorktreeId,
titleUsesTabTitle: false
titleUsesTabTitle: false,
tabTitle: undefined
}
}
// Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there.
@@ -177,7 +191,8 @@ export function resolvePaneKey(
repoConnectionId,
repoConnectionResolved,
owningWorktreeId,
titleUsesTabTitle: paneTitle === undefined
titleUsesTabTitle: paneTitle === undefined,
tabTitle
}
}
@@ -0,0 +1,206 @@
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'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
/**
* The two title slots this path straddles: `tab.title` (what it writes) and the layout's
* `titlesByLeafId` (what only a mounted pane updates). They diverge whenever a hook-driven write
* lands while the pane is unmounted.
*/
function storeWithDivergedTitleSlots(args: {
tabTitle: string
paneSlotTitle: string
}): ReturnType<typeof useAppStore.getState> {
const tab: TerminalTab = {
id: TAB_ID,
ptyId: `pty-${TAB_ID}`,
worktreeId: WORKTREE_ID,
title: args.tabTitle,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
return {
tabsByWorktree: { [WORKTREE_ID]: [tab] },
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
titlesByLeafId: { [LEAF_ID]: args.paneSlotTitle }
}
},
worktreesByRepo: {},
repos: []
} as unknown as ReturnType<typeof useAppStore.getState>
}
describe('hook-driven tab title writes', () => {
it('exposes the tab record title separately from the pane slot title', () => {
const store = storeWithDivergedTitleSlots({
tabTitle: 'Codex - action required',
paneSlotTitle: 'Codex ready'
})
const resolved = resolvePaneKey(store, PANE_KEY)
expect(resolved.title).toBe('Codex ready')
expect(resolved.tabTitle).toBe('Codex - action required')
})
// Why: Orca writes "Codex - action required" itself on a blocked/waiting hook, into `tab.title`
// only. When `done` arrived, the no-op guard compared the resolved title against the PANE slot —
// which still read "Codex ready" — so the write was skipped and the tab kept asserting a question
// the agent had already finished asking, for as long as the pane stayed unmounted.
it('rewrites a stale action-required tab title once the agent reports done', () => {
const store = storeWithDivergedTitleSlots({
tabTitle: 'Codex - action required',
paneSlotTitle: 'Codex ready'
})
const resolved = resolvePaneKey(store, PANE_KEY)
const nextTitle = resolveAgentStatusTerminalTitle(
{ agentType: 'codex', state: 'done' },
resolved.title
)
expect(nextTitle).toBe('Codex ready')
// Comparing against the pane slot is what skipped the write.
expect(
shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.title, nextTitle)
).toBe(false)
// The tab record is the slot this path overwrites, so it is the one that decides.
expect(
shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.tabTitle, nextTitle)
).toBe(true)
})
it('still skips the write when the tab record already holds the resolved title', () => {
const store = storeWithDivergedTitleSlots({
tabTitle: 'Codex ready',
paneSlotTitle: 'Codex ready'
})
const resolved = resolvePaneKey(store, PANE_KEY)
expect(
shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.tabTitle, 'Codex ready')
).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 })
+39 -1
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,6 +24,8 @@ export type WorktreeStatus =
type WorktreeStatusHeuristicOptions = {
liveAgentStatus?: LiveAgentWorktreeStatus
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
/** 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>
}
@@ -73,13 +76,18 @@ function tabHasStatus(
status: 'permission' | 'working',
options: WorktreeStatusHeuristicOptions
): boolean {
const agentStatusPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id]
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 =
@@ -101,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
}
@@ -110,6 +122,30 @@ function tabHasStatus(
)
}
/**
* Pane ids whose title must not drive `status` for this tab. Fresh rows suppress every heuristic;
* 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(
tabId: string,
status: 'permission' | 'working',
options: WorktreeStatusHeuristicOptions
): ReadonlySet<string> | undefined {
const fresh = options.agentStatusPaneIdsByTabId?.[tabId]
if (status !== 'permission') {
return fresh
}
const stale = options.stalePaneIdsByTabId?.[tabId]
if (!stale || stale.size === 0) {
return fresh
}
if (!fresh || fresh.size === 0) {
return stale
}
return new Set([...fresh, ...stale])
}
// Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row.
function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | null): boolean {
if (resolveAgentTypeFromTerminalTitle(title) !== null) {
@@ -139,6 +175,7 @@ export function resolveWorktreeStatus(args: {
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
stalePaneIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
hasPermission: boolean
@@ -155,6 +192,7 @@ export function resolveWorktreeStatus(args: {
args.runtimePaneTitlesByTabId ?? {},
{
agentStatusPaneIdsByTabId: args.agentStatusPaneIdsByTabId,
stalePaneIdsByTabId: args.stalePaneIdsByTabId,
terminalLayoutsByTabId: args.terminalLayoutsByTabId,
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId
}
+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 {