refactor(agents): move renderer display surfaces onto the canonical resolver (tranche 1)

Rebuilt on main after tranche 0 landed. Drops a leftover .test.ts that
contained no tests and duplicated the shipping-baseline helper.
This commit is contained in:
Merge Sim
2026-09-03 18:56:00 -07:00
parent 7106101ed2
commit 77d4880e8d
45 changed files with 808 additions and 574 deletions
@@ -53,7 +53,7 @@ describe('getWorktreeStatus', () => {
)
).toBe('permission')
expect(
getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds)
getWorktreeStatus([makeTerminalTab('⠋ working - codex')], [{ id: 'browser-1' }], livePtyIds)
).toBe('working')
})
})
@@ -0,0 +1,19 @@
import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter'
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentStatus } from '../../../../shared/agent-detection'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
/** Attribute title activity through the canonical identity ladder. */
export function resolveAttributedTitleStatus(
title: string,
launchAgent?: TuiAgent | null
): AgentStatus | null {
return resolveCanonicalPaneAgentIdentity({
launchAgent: launchAgent ?? null,
title,
uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true }
}).agent !== null
? classifyTitleActivity(title)
: null
}
@@ -1,4 +1,4 @@
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
@@ -15,6 +15,8 @@ import {
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { resolveAttributedTitleStatus } from './smart-attention-title-status'
/**
* Ordinal class for the "Smart" sort. Lower number = more attention-demanding.
@@ -291,7 +293,7 @@ export type TabPaneInputSources = {
* stale working-pattern title can't leak through.
*/
export function collectTabPaneInputs(
tab: Pick<TerminalTab, 'id' | 'title'>,
tab: Pick<TerminalTab, 'id' | 'title'> & { launchAgent?: TuiAgent | null },
worktreeLastActivityAt: number,
sources: TabPaneInputSources,
now: number
@@ -326,7 +328,7 @@ export function collectTabPaneInputs(
// Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title.
panes.push({
kind: 'title',
status: classifyTitleActivity(tab.title),
status: resolveAttributedTitleStatus(tab.title, tab.launchAgent),
worktreeLastActivityAt
})
}
@@ -343,7 +345,11 @@ export function collectTabPaneInputs(
if ((leafId !== null && hookLeafIds.has(leafId)) || hasSingleUnmappedHook) {
continue
}
panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt })
panes.push({
kind: 'title',
status: resolveAttributedTitleStatus(title, tab.launchAgent),
worktreeLastActivityAt
})
}
return panes
}
@@ -124,6 +124,25 @@ describe('buildWorktreeAgentRows', () => {
expect(rows[0].agentType).toBe('claude')
})
it('does not resolve retained unknown rows from a bare free-text title', () => {
const retained = makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000, {
entry: makeEntry(ORPHAN_PANE_KEY, 1000, {
agentType: 'unknown',
terminalTitle: 'grok'
}),
tab: { ...makeTab('tab-orphan'), title: 'grok' },
agentType: 'unknown'
})
const rows = buildWorktreeAgentRows({
tabs: [],
entries: [],
retained: [retained],
now: 2000
})
expect(rows[0].agentType).toBe('unknown')
})
it('resolves live unknown rows from the launched tab agent', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'codex', title: 'test-thing-2' })],
@@ -140,7 +159,7 @@ describe('buildWorktreeAgentRows', () => {
expect(rows[0].agentType).toBe('codex')
})
it('prefers an unrelated live title over the launched tab agent for unknown rows', () => {
it('keeps the launched tab agent over an unrelated live title for unknown rows', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'omp', title: '\u280b Codex' })],
entries: [
@@ -153,7 +172,8 @@ describe('buildWorktreeAgentRows', () => {
now: 2000
})
expect(rows[0].agentType).toBe('codex')
// A launch record is a fact Orca owns; a title is a decoration channel.
expect(rows[0].agentType).toBe('omp')
})
it('normalizes live Pi-compatible rows from the launched OMP tab agent', () => {
@@ -1,30 +1,26 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
import { resolveAgentTypeFromTerminalTitle } from './worktree-title-derived-agent-rows'
import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter'
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
import { agentTypeToIconAgent } from '@/lib/agent-status'
/**
* Resolves the sidebar row agent type, prioritizing launch agent configuration
* and normalizing compatible agent kinds.
*/
export function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType {
const launchOwner = { ownerIsLaunch: Boolean(tab?.launchAgent) }
const entryAgentType = resolveCompatibleAgentTypeForOwner(
entry.agentType,
tab?.launchAgent,
launchOwner
)
if (entryAgentType && entryAgentType !== 'unknown') {
return entryAgentType
}
return (
resolveAgentTypeFromTerminalTitle(
entry.terminalTitle ?? tab?.title,
tab?.launchAgent,
launchOwner
) ??
tab?.launchAgent ??
entryAgentType ??
'unknown'
)
const entryAgent = agentTypeToIconAgent(entry.agentType)
const title = entry.terminalTitle ?? tab?.title
const canonical = resolveCanonicalPaneAgentIdentity({
hookAgent: entry.state === 'done' ? null : entryAgent,
hookIsLive: true,
completedHookAgent: entry.state === 'done' ? entryAgent : null,
launchAgent: tab?.launchAgent ?? null,
title,
uncoveredFallback: {
agent: title ? resolveExplicitTerminalTitleAgentType(title) : null,
titleOnly: true
}
})
return canonical.agent ?? tab?.launchAgent ?? entry.agentType ?? 'unknown'
}
@@ -51,8 +51,8 @@ describe('buildTitleDerivedAgentRows', () => {
retained: [],
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'Antigravity',
2: '⠋ Codex'
1: 'idle - antigravity',
2: '⠋ compiling - codex'
}
},
ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] },
@@ -135,7 +135,7 @@ describe('buildTitleDerivedAgentRows', () => {
entries: [],
retained: [],
runtimePaneTitlesByTabId: {
'tab-parent': { 1: '⠋ Codex' },
'tab-parent': { 1: '⠋ parent task - codex' },
'tab-child': { 1: '⠋ Claude Code' }
},
ptyIdsByTabId: {
@@ -222,7 +222,7 @@ describe('buildTitleDerivedAgentRows', () => {
).toEqual([['codex', 'working', 'Codex', '⠼ demo-repo']])
})
it('keeps explicit title identity over the launched agent', () => {
it('keeps launch identity over a conflicting title', () => {
const launchAgent: TuiAgent = 'claude'
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent })],
@@ -236,7 +236,7 @@ describe('buildTitleDerivedAgentRows', () => {
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'working']])
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['claude', 'working']])
})
it('produces no row for a spinner-only title when the tab has no launch identity', () => {
@@ -361,10 +361,10 @@ describe('buildTitleDerivedAgentRows', () => {
})
expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude'])
// Pane reuse: the user exited OpenCode and ran claude in the same pane.
expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude'])
// No owner to defend the pane: naming Claude stays the only available identity.
expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude'])
// Launch ownership remains stronger than the conflicting title.
expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['opencode'])
// A name inside task prose is free text, not identity.
expect(rowsFor('⠋ use Claude Sonnet')).toHaveLength(0)
expect(rowsFor('zsh', 'opencode')).toHaveLength(0)
})
@@ -21,7 +21,12 @@ import {
type CompatibleAgentOwnerOptions
} from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
} from '../../../../shared/terminal-title-agent-type'
import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter'
import type { TuiAgent } from '../../../../shared/tui-agent'
/** Fixed, not per-process: title rows are a pure projection of the current title, so they are
* comparable across restarts in a way a sequenced authority's rows are not. Ordering against
@@ -93,6 +98,10 @@ export function buildTitleDerivedAgentRows(args: {
leafId,
title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
launchAgent:
paneTitleEntries.length === 1 && layout?.root?.type !== 'split'
? tab.launchAgent
: undefined,
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
@@ -114,6 +123,7 @@ export function buildTitleDerivedAgentRows(args: {
leafId,
title: tab.title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
launchAgent: layout?.root?.type === 'leaf' ? tab.launchAgent : undefined,
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
@@ -136,6 +146,7 @@ function buildTitleDerivedAgentRow(args: {
leafId: string
title: string
ownerAgentType: AgentType | null
launchAgent?: TuiAgent
now: number
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
}): DashboardAgentRow | null {
@@ -164,15 +175,26 @@ function buildTitleDerivedAgentRow(args: {
}
const paneKey = makePaneKey(args.tab.id, args.leafId)
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
const titleAgentType = isClaudeAgentsTitle
? 'claude'
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
const canonicalIdentity = resolveCanonicalPaneAgentIdentity({
launchAgent: args.launchAgent ?? null,
title,
uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true }
})
const titleAgentType =
canonicalIdentity.source === 'title'
? (canonicalIdentity.agent as AgentType | null)
: isClaudeAgentsTitle
? 'claude'
: null
// Why: a status frame proves activity, not identity, so the resolver drops it.
// Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940)
// surface only decorated task titles; fall back to the pane's known owner instead
// of hiding the pane. Safe because the `!status || !label` gate above already
// rejects plain shell titles — this path must never manufacture a row from one.
const agentType = titleAgentType ?? args.ownerAgentType
const agentType =
(canonicalIdentity.agent as AgentType | null) ??
args.ownerAgentType ??
(isClaudeAgentsTitle ? 'claude' : null)
if (!agentType) {
return null
}
@@ -342,7 +342,7 @@ describe('workspace space presentation helpers', () => {
it('counts hookless title-derived running agents as active workspace usage', () => {
const count = countWorkspaceSpaceActiveAgents({
worktreeId: 'wt',
tabs: [{ id: 'tab-1', title: 'Codex working' }],
tabs: [{ id: 'tab-1', title: '⠋ working - codex' }],
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimePaneTitlesByTabId: {},
@@ -356,7 +356,7 @@ describe('workspace space presentation helpers', () => {
it('does not count title-derived agents when the terminal has no live pty', () => {
const count = countWorkspaceSpaceActiveAgents({
worktreeId: 'wt',
tabs: [{ id: 'tab-1', title: 'Codex working' }],
tabs: [{ id: 'tab-1', title: '⠋ working - codex' }],
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimePaneTitlesByTabId: {},
@@ -7,6 +7,9 @@ import {
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter'
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import type {
WorkspaceSpaceItem,
@@ -39,7 +42,7 @@ export type WorkspaceSpaceDeleteReadiness = {
export type WorkspaceSpaceAgentActivityInputs = {
worktreeId: string
tabs: readonly Pick<TerminalTab, 'id' | 'title'>[]
tabs: readonly (Pick<TerminalTab, 'id' | 'title'> & { launchAgent?: TuiAgent | null })[]
agentStatusByPaneKey: Record<string, AgentStatusEntry>
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
@@ -72,7 +75,7 @@ function isActiveAgentState(entry: Pick<AgentStatusEntry, 'state'>): boolean {
}
function countTitleActiveAgentsForTab(
tab: Pick<TerminalTab, 'id' | 'title'>,
tab: Pick<TerminalTab, 'id' | 'title'> & { launchAgent?: TuiAgent | null },
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
ptyIdsByTabId: Record<string, string[]>
): number {
@@ -84,12 +87,25 @@ function countTitleActiveAgentsForTab(
if (paneTitles && Object.keys(paneTitles).length > 0) {
return Object.values(paneTitles).filter((title) => {
const status = classifyTitleActivity(title)
return status === 'working' || status === 'permission'
const identity = resolveCanonicalPaneAgentIdentity({
launchAgent: tab.launchAgent ?? null,
title,
uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true }
})
return (status === 'working' || status === 'permission') && identity.agent !== null
}).length
}
const status = classifyTitleActivity(tab.title)
return status === 'working' || status === 'permission' ? 1 : 0
const identity = resolveCanonicalPaneAgentIdentity({
launchAgent: tab.launchAgent ?? null,
title: tab.title,
uncoveredFallback: {
agent: resolveExplicitTerminalTitleAgentType(tab.title),
titleOnly: true
}
})
return (status === 'working' || status === 'permission') && identity.agent !== null ? 1 : 0
}
export function countWorkspaceSpaceActiveAgents({
@@ -28,31 +28,25 @@ function splitLayout(activeLeafId: string | null): TerminalLayoutSnapshot {
}
describe('selectTabAgentTypesByTabId', () => {
it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => {
it('maps each tab through canonical sibling evidence when no layout is hydrated', () => {
const map: Record<string, AgentStatusEntry> = {
'tab-1:leaf-a': entry({ agentType: 'claude' }),
'tab-1:leaf-b': entry({ agentType: 'codex' }),
'tab-2:leaf-a': entry({ agentType: 'codex' })
}
const projection = selectTabAgentTypesByTabId(map)
expect(projection).toEqual({ 'tab-1': 'claude', 'tab-2': 'codex' })
// Parity with the lookup it replaces, for every tab.
for (const tabId of ['tab-1', 'tab-2', 'tab-missing']) {
expect(projection[tabId] ?? null).toBe(findTabAgentEntry(map, tabId)?.agentType ?? null)
}
// Conflicting sibling agents are ambiguous and must not silently choose
// the first insertion-order entry.
expect(projection).toEqual({ 'tab-2': 'codex' })
expect(findTabAgentEntry(map, 'tab-1')?.agentType).toBe('claude')
})
it('a first pane without an agentType yields null even if a later pane has one', () => {
it('uses a sole recognized sibling when the first pane has no agentType', () => {
const map: Record<string, AgentStatusEntry> = {
'tab-1:leaf-a': entry({ agentType: undefined }),
'tab-1:leaf-b': entry({ agentType: 'claude' })
}
// First matching pane wins (claims the tab) with no agentType -> null, exactly
// like findTabAgentEntry(...)?.agentType ?? null.
expect(selectTabAgentTypesByTabId(map)['tab-1'] ?? null).toBe(
findTabAgentEntry(map, 'tab-1')?.agentType ?? null
)
expect(selectTabAgentTypesByTabId(map)['tab-1']).toBe('claude')
})
it('uses the active split leaf regardless of pane-map insertion order', () => {
@@ -1,9 +1,12 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
isNativeChatTabWideFallbackSafe,
resolveNativeChatActiveLayoutLeafId
} from '../native-chat/native-chat-leaf-routing'
import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter'
import { agentTypeToIconAgent } from '@/lib/agent-status'
type TabBarAgentProjectionSelectorDependencies = {
onStatusEntryVisited?: (paneKey: string) => void
@@ -65,10 +68,17 @@ function projectTabAgentTypesByTabId(
continue
}
const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`]
if (entry?.agentType != null) {
byTabId[tabId] = entry.agentType
const entryAgent = agentTypeToIconAgent(entry?.agentType)
const identity = resolveCanonicalPaneAgentIdentity({
hookAgent: entry?.state === 'done' ? null : entryAgent,
hookIsLive: true,
completedHookAgent: entry?.state === 'done' ? entryAgent : null
})
if (identity.agent != null) {
byTabId[tabId] = identity.agent
}
}
const siblingAgentsByTabId = new Map<string, TuiAgent[]>()
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
dependencies?.onStatusEntryVisited?.(paneKey)
const colon = paneKey.indexOf(':')
@@ -79,9 +89,21 @@ function projectTabAgentTypesByTabId(
if (claimed.has(tabId)) {
continue
}
claimed.add(tabId)
if (entry.agentType != null) {
byTabId[tabId] = entry.agentType
const entryAgent = agentTypeToIconAgent(entry.agentType)
if (!entryAgent) {
continue
}
const agents = siblingAgentsByTabId.get(tabId)
if (agents) {
agents.push(entryAgent)
} else {
siblingAgentsByTabId.set(tabId, [entryAgent])
}
}
for (const [tabId, siblingAgents] of siblingAgentsByTabId) {
const identity = resolveCanonicalPaneAgentIdentity({ siblingAgents, allowSibling: true })
if (identity.agent != null) {
byTabId[tabId] = identity.agent
}
}
return byTabId
@@ -144,7 +144,7 @@ describe('resolveTerminalTabActivityStatus', () => {
vi.setSystemTime(31 * 60 * 1000)
expect(
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'Codex working' },
tab: { id: TAB_ID, title: '⠋ working - codex' },
agentStatusByPaneKey: { [stale.paneKey]: stale },
ptyIdsByTabId: LIVE_PTY
})
@@ -155,7 +155,7 @@ describe('resolveTerminalTabActivityStatus', () => {
const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true })
expect(
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'Codex working' },
tab: { id: TAB_ID, title: '⠋ working - codex' },
agentStatusByPaneKey: { [restored.paneKey]: restored },
ptyIdsByTabId: LIVE_PTY
})
@@ -168,7 +168,9 @@ describe('resolveTerminalTabActivityStatus', () => {
resolveTerminalTabActivityStatus({
tab: TAB,
agentStatusByPaneKey: { [restored.paneKey]: restored },
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working', 2: 'Claude working' } },
runtimePaneTitlesByTabId: {
[TAB_ID]: { 1: '⠋ working - codex', 2: '⠋ working - claude' }
},
ptyIdsByTabId: LIVE_PTY,
terminalLayout: {
root: {
@@ -214,8 +216,8 @@ describe('resolveTerminalTabActivityStatus', () => {
it('does not treat a preserved title on a sleeping tab as activity', () => {
expect(
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'Codex working' },
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } },
tab: { id: TAB_ID, title: '⠋ working - codex' },
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ working - codex' } },
ptyIdsByTabId: { [TAB_ID]: [] }
})
).toBe('inactive')
@@ -30,7 +30,7 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent
// Control: the named-provider path this must stay at parity with.
it('reports working for a named-provider title', () => {
const status = resolveTerminalTabActivityStatus({
tab: { id: 'tab-1', title: 'claude [working]' } as TerminalTab,
tab: { id: 'tab-1', title: '⠋ working - claude' } as TerminalTab,
ptyIdsByTabId: { 'tab-1': ['pty-0'] }
})
@@ -12,7 +12,7 @@ describe('resolveNativeChatLeafTitleAgent', () => {
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-2',
panes,
runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: 'Codex - working' },
runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: '⠋ working - codex' },
tabLabel: 'PowerShell'
})
).toBe('codex')
@@ -40,14 +40,37 @@ describe('resolveNativeChatLeafTitleAgent', () => {
).toBeNull()
})
it('falls back to the terminal title in a single pane', () => {
it('keeps launch identity over a conflicting single-pane terminal title', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes: [panes[0]],
runtimePaneTitlesByPaneId: {},
terminalTitle: 'OpenClaude'
terminalTitle: 'Claude Code',
launchAgent: 'openclaude'
})
).toBe('openclaude')
})
it('uses anchored title identity when no stronger evidence exists', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes: [panes[0]],
runtimePaneTitlesByPaneId: {},
terminalTitle: 'Claude Code'
})
).toBe('claude')
})
it('rejects a bare free-text title when no stronger evidence exists', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes: [panes[0]],
runtimePaneTitlesByPaneId: {},
terminalTitle: 'grok'
})
).toBeNull()
})
})
@@ -1,5 +1,9 @@
import type { TuiAgent } from '../../../../shared/tui-agent'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import {
resolveCanonicalPaneAgentIdentity,
type ForegroundProcessProof
} from '../../../../shared/pane-agent-identity-adapter'
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
export type NativeChatLeafTitlePane = {
id: number
@@ -12,6 +16,14 @@ export type NativeChatLeafTitleAgentInput = {
runtimePaneTitlesByPaneId: Readonly<Record<number, string>>
tabLabel?: string | null
terminalTitle?: string | null
/** Optional pane-scoped evidence for callers that already hold it. */
hookAgent?: TuiAgent | null
completedHookAgent?: TuiAgent | null
launchAgent?: TuiAgent | null
sleepingSessionAgent?: TuiAgent | null
siblingAgent?: TuiAgent | null
processAgent?: TuiAgent | null
processProof?: ForegroundProcessProof | null
}
export function resolveNativeChatLeafTitleAgent({
@@ -19,25 +31,41 @@ export function resolveNativeChatLeafTitleAgent({
panes,
runtimePaneTitlesByPaneId,
tabLabel,
terminalTitle
terminalTitle,
hookAgent,
completedHookAgent,
launchAgent,
sleepingSessionAgent,
siblingAgent,
processAgent,
processProof
}: NativeChatLeafTitleAgentInput): TuiAgent | null {
if (!leafId) {
return null
}
const targetPane = panes.find((pane) => pane.leafId === leafId)
const paneAgent = targetPane
? resolveCommittedTitleAgentType(runtimePaneTitlesByPaneId[targetPane.id] ?? '')
: null
if (paneAgent) {
return paneAgent
}
// Tab titles can lag pane focus in split layouts, so use them only when there
// is no sibling leaf they could accidentally describe.
if (panes.length > 1) {
return null
}
return (
resolveCommittedTitleAgentType(tabLabel ?? '') ??
resolveCommittedTitleAgentType(terminalTitle ?? '')
)
const paneTitle = targetPane ? (runtimePaneTitlesByPaneId[targetPane.id] ?? '') : null
// Tab titles can lag pane focus, so only a single-leaf tab may use that fallback.
const title = paneTitle?.trim()
? paneTitle
: panes.length > 1
? null
: (tabLabel ?? terminalTitle ?? null)
const resolved = resolveCanonicalPaneAgentIdentity({
hookAgent,
hookIsLive: true,
completedHookAgent,
launchAgent,
processProof,
foregroundAgent: processAgent,
sleepingSessionAgent,
siblingAgent,
allowSibling: siblingAgent != null,
title,
uncoveredFallback: {
agent: title ? resolveExplicitTerminalTitleAgentType(title) : null,
titleOnly: true
}
})
return resolved.agent
}
@@ -15,6 +15,7 @@ import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import { resolveCanonicalPaneAgentIdentity } from '../../../../../shared/pane-agent-identity-adapter'
import type { TuiAgent } from '../../../../../shared/tui-agent'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config'
@@ -27,7 +28,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
// can't drift and silently reintroduce the icon bug this fix closes.
session.paneHasLiveHookAgentIcon = (state: ReturnType<typeof useAppStore.getState>): boolean => {
const entry = state.agentStatusByPaneKey[session.cacheKey]
return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType))
if (entry?.state === 'done') {
return false
}
const identity = resolveCanonicalPaneAgentIdentity({
hookAgent: agentTypeToIconAgent(entry?.agentType),
hookIsLive: true
})
return Boolean(identity.agent)
}
// Why: one ladder for both launch-agent signals; a second copy could drift.
const resolveLaunchAgentCandidate = (
@@ -82,7 +82,8 @@ describe('createTerminalTabAgentTypeSelector', () => {
}
}
expect(select({}, 'tab-1', foreground)).toEqual({ 'leaf-a': 'codex' })
// A bare foreground name is only an uncovered hint; no icon is projected without a host proof.
expect(select({}, 'tab-1', foreground)).toEqual({})
expect(select({ 'tab-1:leaf-a': entry('claude') }, 'tab-1', foreground)).toEqual({
'leaf-a': 'claude'
})
@@ -97,4 +98,23 @@ describe('createTerminalTabAgentTypeSelector', () => {
})
).toEqual({})
})
it('lets a fresh foreground proof outrank a completed hook for the same pane', () => {
const select = createTerminalTabAgentTypeSelector()
expect(
select({ 'tab-1:leaf-a': entry('claude', 'done') }, 'tab-1', {
'tab-1:leaf-a': {
agent: 'codex',
processProof: {
agent: 'codex',
processIncarnation: 'fixture-process',
authorityId: 'fixture-authority',
capturedAgeMs: 10,
validForMs: 1_000
},
shellForeground: false
}
})
).toEqual({ 'leaf-a': 'codex' })
})
})
@@ -1,5 +1,10 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
import {
resolveCanonicalPaneAgentIdentity,
type ForegroundProcessProof
} from '../../../../shared/pane-agent-identity-adapter'
import { agentTypeToIconAgent } from '@/lib/agent-status'
export type TerminalTabAgentTypeState = Record<string, AgentStatusEntry>
export type TerminalTabAgentTypesByLeaf = Readonly<Record<string, AgentType>>
@@ -13,6 +18,13 @@ const EMPTY_FOREGROUND_AGENT_BY_PANE_KEY: Record<string, PaneForegroundAgentEntr
{}
)
type PaneAgentProjectionEvidence = {
hookAgent?: ReturnType<typeof agentTypeToIconAgent>
completedHookAgent?: ReturnType<typeof agentTypeToIconAgent>
processAgent?: PaneForegroundAgentEntry['agent']
processProof?: ForegroundProcessProof | null
}
function reuseRecordIfEqual(
previous: TerminalTabAgentTypesByLeaf | undefined,
next: Record<string, AgentType>
@@ -43,29 +55,41 @@ export function createTerminalTabAgentTypeSelector(
// Zustand notifications skip the global scan entirely.
if (state !== cachedState || foreground !== cachedForeground) {
const previousByTabId = cachedByTabId
const nextByTabId = new Map<string, Record<string, AgentType>>()
const evidenceByPaneKey = new Map<string, PaneAgentProjectionEvidence>()
for (const [paneKey, entry] of Object.entries(state)) {
dependencies.onEntryVisited?.(paneKey)
if (!entry.agentType) {
const entryAgent = agentTypeToIconAgent(entry.agentType)
if (!entryAgent) {
continue
}
const separator = paneKey.indexOf(':')
if (separator <= 0) {
continue
}
const entryTabId = paneKey.slice(0, separator)
const leafId = paneKey.slice(separator + 1)
const byLeaf = nextByTabId.get(entryTabId)
if (byLeaf) {
byLeaf[leafId] = entry.agentType
} else {
nextByTabId.set(entryTabId, { [leafId]: entry.agentType })
}
evidenceByPaneKey.set(
paneKey,
entry.state === 'done' ? { completedHookAgent: entryAgent } : { hookAgent: entryAgent }
)
}
for (const [paneKey, entry] of Object.entries(foreground)) {
if (!entry.agent || entry.shellForeground || entry.routingRevoked) {
continue
}
const existing = evidenceByPaneKey.get(paneKey) ?? {}
evidenceByPaneKey.set(paneKey, {
...existing,
processAgent: entry.agent,
processProof: entry.processProof
})
}
const nextByTabId = new Map<string, Record<string, AgentType>>()
for (const [paneKey, evidence] of evidenceByPaneKey) {
const identity = resolveCanonicalPaneAgentIdentity({
hookAgent: evidence.hookAgent,
hookIsLive: evidence.hookAgent != null,
completedHookAgent: evidence.completedHookAgent,
foregroundAgent: evidence.processAgent,
processProof: evidence.processProof
})
if (!identity.agent) {
continue
}
const separator = paneKey.indexOf(':')
if (separator <= 0) {
continue
@@ -74,9 +98,9 @@ export function createTerminalTabAgentTypeSelector(
const leafId = paneKey.slice(separator + 1)
const byLeaf = nextByTabId.get(entryTabId)
if (byLeaf) {
byLeaf[leafId] ??= entry.agent
byLeaf[leafId] = identity.agent
} else {
nextByTabId.set(entryTabId, { [leafId]: entry.agent })
nextByTabId.set(entryTabId, { [leafId]: identity.agent })
}
}
@@ -119,7 +119,9 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController
panes: managerRef.current?.getPanes() ?? [],
runtimePaneTitlesByPaneId,
tabLabel: hasSingleKnownLeaf ? unifiedTabLabel : null,
terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null
terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null,
launchAgent: terminalTab?.launchAgent ?? null,
sleepingSessionAgent: (structuredSessionAgent as TuiAgent | null) ?? null
})
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
@@ -127,6 +129,8 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController
getNativeChatLeafIds,
getTabWideAgentHintLeafId,
runtimePaneTitlesByPaneId,
structuredSessionAgent,
terminalTab?.launchAgent,
terminalTab?.title,
unifiedTabLabel
]
@@ -110,8 +110,10 @@ describe('resolveOpenTabOccupantAgent', () => {
).toBe('grok')
})
it('uses the tab-strip title identity when hooks have not reported yet', () => {
expect(resolve({ title: 'grok' })).toBe('grok')
it('does not guess an occupant from a bare title name', () => {
// `grok` is free-text-only evidence; the canonical resolver requires an anchored owner
// suffix (for example `Task - grok`) before a title-only fallback can identify a pane.
expect(resolve({ title: 'grok' })).toBeNull()
})
it('does not let a grok mention in the title steal a launched Claude pane', () => {
@@ -3,7 +3,6 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
@@ -62,25 +61,20 @@ export function resolveOpenTabOccupantAgent({
const sleepingSessionAgent = focusedPaneKey
? (sleepingAgentSessionsByPaneKey[focusedPaneKey]?.agent ?? null)
: null
const oscTitle = title?.trim() || ''
const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(oscTitle)
const fallbackAgentSignal = launchAgent
? explicitTitleAgent === launchAgent
: Boolean(explicitTitleAgent || siblingHookAgent)
return resolveTabAgentFromSignals({
hasObservedAgentSignal: Boolean(
hookAgent || focusedCompletedHookAgent || processAgent || fallbackAgentSignal
hookAgent || focusedCompletedHookAgent || processAgent || launchAgent || siblingHookAgent
),
// Search does not observe OSC 133;D, so do not apply local-only exit clearing.
isRemote: true,
title: oscTitle,
title: title?.trim() || '',
defaultTitle,
hookAgent,
siblingHookAgent,
focusedCompletedHookAgent,
siblingCompletedHookAgent,
processAgent,
processProof: process?.processProof,
processShellForeground: Boolean(process?.shellForeground),
sleepingSessionAgent,
launchAgent
@@ -41,7 +41,7 @@ function titleRowObservation(now: number): AgentStatusObservation {
tabs: [makeTab('tab-1')],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Codex' } },
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ observing - codex' } },
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout() },
now
+51 -111
View File
@@ -1,14 +1,10 @@
import { isShellProcess } from '../../../shared/agent-detection'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
} from '../../../shared/terminal-title-agent-type'
import {
resolveCompatibleAgentTypeForOwner,
shareCompatibleTitleIdentityGroup
} from '../../../shared/agent-title-owner'
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner'
resolveCanonicalPaneAgentIdentity,
type ForegroundProcessProof
} from '../../../shared/pane-agent-identity-adapter'
import type { PaneAgentRunKey } from '../../../shared/pane-agent-identity-resolver'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import type { TuiAgent } from '../../../shared/tui-agent'
// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence.
@@ -17,21 +13,6 @@ function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
}
/**
* Resolves wrapper-compatible signal identity against the pane owner.
*/
function resolveSignalAgentForLaunchOwner(
signalAgent: TuiAgent | null | undefined,
ownerAgent: TuiAgent | null,
ownerIsLaunch = false
): TuiAgent | null {
if (!signalAgent) {
return null
}
return (resolveCompatibleAgentTypeForOwner(signalAgent, ownerAgent, { ownerIsLaunch }) ??
signalAgent) as TuiAgent
}
/**
* Probe-free evidence a launched agent exited: title shows no agent, no live
* hook remains, and either the hook completed or observed activity vanished.
@@ -62,8 +43,15 @@ export function resolveLaunchedAgentExitEvidence(args: {
}
/**
* Identity-first precedence: live hook > process > title > completed > sleeping
* > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence.
* Resolve the tab's display identity through the canonical pane ladder.
*
* This adapter only translates the tab's focused/sibling slots. A bare process name remains a
* hint until a host-stamped proof is supplied, and title parsing belongs to the canonical call.
* The source order is live-hook > process > launch > completed-hook > sleeping-session > sibling
* > title. Launch intentionally outranks completed-hook for now: a completed hook is newer
* evidence in the abstract, but run-key staleness is not wired (undefined run keys are eligible),
* so it can describe a previous occupant of a reused pane; launch is scoped to this pane's setup.
* Once production supplies run keys, the ordering must be revisited (see the expiry test).
*/
export function resolveTabAgentFromSignals(args: {
hasObservedAgentSignal: boolean
@@ -74,98 +62,50 @@ export function resolveTabAgentFromSignals(args: {
siblingHookAgent?: TuiAgent | null
focusedCompletedHookAgent?: TuiAgent | null
siblingCompletedHookAgent?: TuiAgent | null
siblingAgents?: readonly TuiAgent[]
processAgent?: TuiAgent | null
processProof?: ForegroundProcessProof | null
processShellForeground?: boolean
sleepingSessionAgent?: TuiAgent | null
launchAgent?: TuiAgent
hookRun?: PaneAgentRunKey
completedHookRun?: PaneAgentRunKey
launchRun?: PaneAgentRunKey
sleepingRun?: PaneAgentRunKey
currentRun?: PaneAgentRunKey
}): TuiAgent | null {
const launchAgent = args.launchAgent ?? null
// Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP).
const ownerRecord = resolvePaneAgentOwnerRecord({
launchAgent,
hookAgent: args.hookAgent,
completedHookAgent: args.focusedCompletedHookAgent,
sleepingSessionAgent: args.sleepingSessionAgent
})
const owner = (ownerRecord?.agent ?? null) as TuiAgent | null
const ownerIsLaunch = ownerRecord?.ownerIsLaunch === true
// The live/idle split governs title override; siblings normalize against launch intent only.
const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner, ownerIsLaunch)
const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(
const siblingAgents = [
args.siblingHookAgent,
launchAgent,
Boolean(launchAgent)
)
// Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there.
const processProvesShell = !args.isRemote && args.processShellForeground === true
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
const idleIdentitySuppressed =
!args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook
const idleFocusedIdentity = idleIdentitySuppressed
? null
: resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner, ownerIsLaunch)
// Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity.
const idleSiblingIdentity = resolveSignalAgentForLaunchOwner(
args.siblingCompletedHookAgent,
launchAgent,
Boolean(launchAgent)
)
const sleepingSessionAgent = args.sleepingSessionAgent ?? null
// Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins.
const rawTitleAgent = resolveExplicitTerminalTitleAgentType(args.title)
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(rawTitleAgent, owner, ownerIsLaunch)
const priorIdentity = idleFocusedIdentity ?? launchAgent
const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title)
// Why: a "claude" token in another agent's task text is a mention, not identity, so it must
// not take a pane from its known owner — only a title that PRESENTS Claude may (#8940).
const titleClaimsIdentity =
explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title)
// Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal.
// Raw title group, not the fallback-rewritten agent: inferred Pi owners would otherwise treat an OMP wrapper title as a different identity.
const titleReclaimsReusedPane =
priorIdentity !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== priorIdentity &&
!shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) &&
titleClaimsIdentity &&
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle)
// Why: native OpenCode titles lack a provider generation and cannot displace durable ownership.
const titleAgent =
processProvesShell ||
sleepingSessionAgent ||
(nativeOpenCodeTitle && idleFocusedIdentity !== null)
? null
: titleReclaimsReusedPane
? explicitTitleAgent
: priorIdentity
? null
: explicitTitleAgent
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
...(args.siblingAgents ?? [])
].filter((agent): agent is TuiAgent => agent !== null && agent !== undefined)
const identity = resolveCanonicalPaneAgentIdentity({
hookAgent: args.hookAgent,
hookIsLive: true,
hookRun: args.hookRun,
completedHookAgent: args.focusedCompletedHookAgent,
completedHookRun: args.completedHookRun,
launchAgent: args.launchAgent ?? null,
launchRun: args.launchRun,
foregroundAgent: args.processAgent,
processProof: args.processProof,
sleepingSessionAgent: args.sleepingSessionAgent,
sleepingRun: args.sleepingRun,
siblingAgents,
allowSibling: true,
title: args.title,
defaultTitle: args.defaultTitle,
isRemote: args.isRemote,
hasObservedAgentSignal: args.hasObservedAgentSignal,
hookAgent: liveFocusedIdentity,
siblingHookAgent: liveSiblingIdentity,
hasCompletedHook,
processAgent: args.processAgent,
processShellForeground: args.processShellForeground
...(siblingAgents.length === 0
? {
uncoveredFallback: {
agent: resolveExplicitTerminalTitleAgentType(args.title),
titleOnly: true
}
}
: {}),
currentRun: args.currentRun
})
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
// Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon.
const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner, ownerIsLaunch)
return (
liveFocusedIdentity ??
processAgent ??
titleAgent ??
idleFocusedIdentity ??
sleepingSessionAgent ??
activeLaunchAgent ??
liveSiblingIdentity ??
idleSiblingIdentity
)
if (!args.isRemote && args.processShellForeground && identity.source === 'title') {
return null
}
return identity.agent
}
@@ -6,7 +6,7 @@ import {
resolveCanonicalPaneAgentIdentity,
type CanonicalPaneAgentIdentity
} from '../../../shared/pane-agent-identity-adapter'
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
import { resolveShippingTabAgentBaseline } from './tab-agent-identity-shipping-baseline'
import type { TuiAgent } from '../../../shared/tui-agent'
const AGENTS: readonly TuiAgent[] = ['claude', 'codex']
@@ -63,7 +63,7 @@ function realResult(values: readonly (TuiAgent | null)[], title: string, remote:
const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] = values
// The seven slots model steady-state observations; this runtime memory bit is intentionally
// held true instead of adding an eighth dimension to the approved 17,496-shape table.
return resolveTabAgentFromSignals({
return resolveShippingTabAgentBaseline({
hasObservedAgentSignal: true,
isRemote: remote,
title,
@@ -81,6 +81,14 @@ function realResult(values: readonly (TuiAgent | null)[], title: string, remote:
function runDecisionTable(withProof: boolean) {
let disagreements = 0
let flipped = 0
const residualShapes: {
slots: Record<string, TuiAgent | null>
title: string
remote: boolean
shipping: TuiAgent | null
canonical: TuiAgent | null
source: string
}[] = []
const breakdown: Breakdown = {
launch: 0,
'completed-hook': 0,
@@ -100,6 +108,18 @@ function runDecisionTable(withProof: boolean) {
if (canonical.source !== null) {
breakdown[canonical.source] += 1
}
if (canonical.source === 'sibling' || canonical.source === 'title') {
const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] =
values
residualShapes.push({
slots: { hook, siblingHook, completed, siblingCompleted, process, sleeping, launch },
title,
remote,
shipping: real,
canonical: canonical.agent,
source: canonical.source
})
}
}
if (!withProof) {
const proven = canonicalResult(values, title, true)
@@ -116,7 +136,7 @@ function runDecisionTable(withProof: boolean) {
}
}
}
return { disagreements, flipped, breakdown }
return { disagreements, flipped, breakdown, residualShapes }
}
describe('renderer ladder decision table', () => {
@@ -127,15 +147,18 @@ describe('renderer ladder decision table', () => {
shapes: SHAPE_COUNT,
proofOmitted: proofFree,
freshProof,
flippedByAddingProof: proofFree.flipped
flippedByAddingProof: proofFree.flipped,
siblingAndTitleResiduals: {
proofOmitted: proofFree.residualShapes,
freshProof: freshProof.residualShapes
}
}
writeFileSync(
join(tmpdir(), 'orca-pane-agent-identity-decision-table-real.json'),
`${JSON.stringify(result, null, 2)}\n`
)
// Re-derived against resolveTabAgentFromSignals (not a hand-written model). These differ from
// the approved 2,520/648 totals and 396/144/72/36 breakdown; see the PR comment.
expect(proofFree).toEqual({
// Replayed against the real pre-tranche shipping function (not a hand-written model).
expect(proofFree).toMatchObject({
disagreements: 2_622,
flipped: 1_872,
breakdown: {
@@ -147,7 +170,7 @@ describe('renderer ladder decision table', () => {
title: 6
}
})
expect(freshProof).toEqual({
expect(freshProof).toMatchObject({
disagreements: 658,
flipped: 0,
breakdown: {
@@ -159,6 +182,10 @@ describe('renderer ladder decision table', () => {
title: 2
}
})
// The concrete sibling/title residuals are written above; keeping their cardinality asserted
// prevents an aggregate count from silently hiding a newly introduced shape.
expect(proofFree.residualShapes).toHaveLength(60)
expect(freshProof.residualShapes).toHaveLength(8)
expect(proofFree.flipped).toBe(1_872)
})
@@ -0,0 +1,104 @@
import { isShellProcess } from '../../../shared/agent-detection'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
} from '../../../shared/terminal-title-agent-type'
import {
resolveCompatibleAgentTypeForOwner,
shareCompatibleTitleIdentityGroup
} from '../../../shared/agent-title-owner'
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner'
import type { TuiAgent } from '../../../shared/tui-agent'
/**
* Byte-for-byte snapshot of the pre-tranche-1 tab resolver. It is test-only reference behavior:
* production callers use `tab-agent-from-signals.ts`, while the decision table needs a stable
* implementation to measure the renderer migration against.
*/
export function resolveShippingTabAgentBaseline(args: {
hasObservedAgentSignal: boolean
isRemote: boolean
title: string
defaultTitle?: string
hookAgent: TuiAgent | null
siblingHookAgent?: TuiAgent | null
focusedCompletedHookAgent?: TuiAgent | null
siblingCompletedHookAgent?: TuiAgent | null
processAgent?: TuiAgent | null
processShellForeground?: boolean
sleepingSessionAgent?: TuiAgent | null
launchAgent?: TuiAgent
}): TuiAgent | null {
const launchAgent = args.launchAgent ?? null
const ownerRecord = resolvePaneAgentOwnerRecord({
launchAgent,
hookAgent: args.hookAgent,
completedHookAgent: args.focusedCompletedHookAgent,
sleepingSessionAgent: args.sleepingSessionAgent
})
const owner = (ownerRecord?.agent ?? null) as TuiAgent | null
const ownerIsLaunch = ownerRecord?.ownerIsLaunch === true
const normalize = (
signal: TuiAgent | null | undefined,
signalOwner: TuiAgent | null,
launch = false
) =>
signal
? ((resolveCompatibleAgentTypeForOwner(signal, signalOwner, { ownerIsLaunch: launch }) ??
signal) as TuiAgent)
: null
const liveFocused = normalize(args.hookAgent, owner, ownerIsLaunch)
const liveSibling = normalize(args.siblingHookAgent, launchAgent, Boolean(launchAgent))
const processShell = !args.isRemote && args.processShellForeground === true
const hasCompleted =
args.focusedCompletedHookAgent !== null && args.focusedCompletedHookAgent !== undefined
const noTitle =
args.title.trim().length > 0 &&
(isShellProcess(args.title.trim()) || args.title.trim() === args.defaultTitle?.trim())
const idleFocused =
!args.isRemote && (noTitle || processShell) && hasCompleted
? null
: normalize(args.focusedCompletedHookAgent, owner, ownerIsLaunch)
const idleSibling = normalize(args.siblingCompletedHookAgent, launchAgent, Boolean(launchAgent))
const explicitTitle = normalize(
resolveExplicitTerminalTitleAgentType(args.title),
owner,
ownerIsLaunch
)
const prior = idleFocused ?? launchAgent
const nativeOpenCode = explicitTitle === 'opencode' && isOpenCodeNativeTitle(args.title)
const titleClaims = explicitTitle !== 'claude' || isClaudeIdentityFrameTitle(args.title)
const titleReclaims =
prior !== null &&
explicitTitle !== null &&
explicitTitle !== prior &&
!shareCompatibleTitleIdentityGroup(resolveExplicitTerminalTitleAgentType(args.title), prior) &&
titleClaims &&
(args.hasObservedAgentSignal || hasCompleted || nativeOpenCode)
const titleAgent =
processShell || args.sleepingSessionAgent || (nativeOpenCode && idleFocused !== null)
? null
: titleReclaims
? explicitTitle
: prior
? null
: explicitTitle
const launchedExit =
!liveFocused &&
!liveSibling &&
!args.processAgent &&
((!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) ||
(noTitle && (hasCompleted || (!args.isRemote && args.hasObservedAgentSignal))))
const processAgent = normalize(args.processAgent, owner, ownerIsLaunch)
return (
liveFocused ??
processAgent ??
titleAgent ??
idleFocused ??
args.sleepingSessionAgent ??
(launchedExit ? null : launchAgent) ??
liveSibling ??
idleSibling
)
}
@@ -26,16 +26,17 @@ function oracleAnyTabAgent(
tabId: string,
excludedLeafId?: string
): TuiAgent | null {
const agents = new Set<TuiAgent>()
for (const [paneKey, entry] of Object.entries(map)) {
const parsed = parsePaneKey(paneKey)
if (parsed?.tabId === tabId && parsed.leafId !== excludedLeafId) {
const agent = entry.state === 'done' ? null : agentTypeToIconAgent(entry.agentType)
if (agent) {
return agent
agents.add(agent)
}
}
}
return null
return agents.size === 1 ? [...agents][0]! : null
}
function oracleAnyCompletedTabAgent(
@@ -43,16 +44,17 @@ function oracleAnyCompletedTabAgent(
tabId: string,
excludedLeafId?: string
): TuiAgent | null {
const agents = new Set<TuiAgent>()
for (const [paneKey, entry] of Object.entries(map)) {
const parsed = parsePaneKey(paneKey)
if (parsed?.tabId === tabId && parsed.leafId !== excludedLeafId) {
const agent = entry.state === 'done' ? agentTypeToIconAgent(entry.agentType) : null
if (agent) {
return agent
agents.add(agent)
}
}
}
return null
return agents.size === 1 ? [...agents][0]! : null
}
function oracleAnyRetainedTabAgent(
@@ -60,16 +62,17 @@ function oracleAnyRetainedTabAgent(
tabId: string,
excludedLeafId?: string
): TuiAgent | null {
const agents = new Set<TuiAgent>()
for (const [paneKey, retained] of Object.entries(map)) {
const parsed = parsePaneKey(paneKey)
if (parsed?.tabId === tabId && parsed.leafId !== excludedLeafId) {
const agent = agentTypeToIconAgent(retained.agentType)
if (agent) {
return agent
agents.add(agent)
}
}
}
return null
return agents.size === 1 ? [...agents][0]! : null
}
function activeLeafOf(layout: TerminalLayoutSnapshot | undefined): string | null {
@@ -310,17 +313,17 @@ describe('tab agent status index parity with the pre-index full-map scan', () =>
}
})
it('returns the first done sibling in insertion order when siblings run different agents', () => {
it('returns no sibling identity when completed siblings name different agents', () => {
const map = {
[`tab-1:${leafId(0)}`]: statusEntry(`tab-1:${leafId(0)}`, 'done', 'codex'),
[`tab-1:${leafId(1)}`]: statusEntry(`tab-1:${leafId(1)}`, 'done', 'claude'),
[`tab-1:${leafId(2)}`]: statusEntry(`tab-1:${leafId(2)}`, 'done', 'gemini')
}
expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(2)), 'tab-1')).toBe('codex')
expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(0)), 'tab-1')).toBe('claude')
// Fresh identity, reversed insertion order → first match flips.
expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(2)), 'tab-1')).toBeNull()
expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(0)), 'tab-1')).toBeNull()
// Conflicting same-rank sibling identity is ambiguous, regardless of insertion order.
const reversed = Object.fromEntries(Object.entries(map).toReversed())
expect(resolveSiblingCompletedTabAgent(reversed, layoutOf(leafId(2)), 'tab-1')).toBe('claude')
expect(resolveSiblingCompletedTabAgent(reversed, layoutOf(leafId(2)), 'tab-1')).toBeNull()
})
it('excludes the active leaf and skips non-iconable agents', () => {
+10 -8
View File
@@ -3,14 +3,15 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { agentTypeToIconAgent } from './agent-status'
import { resolveCanonicalPaneAgentIdentity } from '../../../shared/pane-agent-identity-adapter'
/**
* Per-tab index of icon-capable agent panes for the tab-bar resolvers in
* `tab-agent.ts`. Without it each of ~200 mounted tabs re-scanned (and
* re-parsed the pane key of) the whole global status map on every render.
*
* Panes keep the source map's insertion order because the resolvers return the
* FIRST match — order decides which icon a split tab shows.
* Panes keep the source map's insertion order for stable projections; the canonical resolver
* fences conflicting same-rank identities instead of letting insertion order choose an icon.
*/
export type TabAgentPane = { readonly leafId: string; readonly agent: TuiAgent }
@@ -108,10 +109,11 @@ export function firstTabAgentExcludingLeaf(
panes: readonly TabAgentPane[],
excludedLeafId?: string
): TuiAgent | null {
for (const pane of panes) {
if (pane.leafId !== excludedLeafId) {
return pane.agent
}
}
return null
const siblingAgents = panes
.filter((pane) => pane.leafId !== excludedLeafId)
.map((pane) => pane.agent)
return resolveCanonicalPaneAgentIdentity({
siblingAgents,
allowSibling: true
}).agent
}
@@ -109,7 +109,7 @@ describe('OpenCode native title tab identity', () => {
})
it.each(identityScenarios)(
'uses native OpenCode identity for a %s tab with stale Claude metadata',
'keeps Claude launch identity for a %s tab despite a conflicting OpenCode title',
(_name, extra) => {
expect(
resolveTabAgentFromSignals({
@@ -120,11 +120,11 @@ describe('OpenCode native title tab identity', () => {
launchAgent: 'claude',
siblingHookAgent: extra.siblingHookAgent
})
).toBe('opencode')
).toBe('claude')
}
)
it('reclaims stale Claude identity loaded from a persisted tab', () => {
it('keeps persisted Claude launch identity over a conflicting OpenCode title', () => {
const parsed = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: 'worktree-1',
@@ -146,7 +146,7 @@ describe('OpenCode native title tab identity', () => {
hookAgent: null,
launchAgent: restoredTab.launchAgent
})
).toBe('opencode')
).toBe('claude')
})
it('keeps current sleeping Claude ownership over a replayed OpenCode title', () => {
@@ -253,7 +253,7 @@ describe('OpenCode native title tab identity', () => {
sleepingSessionAgent: 'claude',
launchAgent: 'claude'
})
).toBe('codex')
).toBe('claude')
for (const title of [
'OpenCode ready',
@@ -286,7 +286,7 @@ describe('OpenCode native title tab identity', () => {
await Promise.resolve()
})
expect(latestAgent).toBe('opencode')
expect(latestAgent).toBe('claude')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
expect(getForegroundProcess).not.toHaveBeenCalled()
const paneKey = makePaneKey('opencode-tab', FOCUSED_LEAF_ID)
@@ -26,7 +26,7 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => {
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
).toBe('pi')
expect(
resolveTabAgentFromSignals({
@@ -36,7 +36,7 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => {
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
).toBe('pi')
expect(
resolveTabAgentFromSignals({
@@ -57,7 +57,7 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => {
hookAgent: 'omp',
launchAgent: 'pi'
})
).toBe('pi')
).toBe('omp')
expect(
resolveTabAgentFromSignals({
@@ -185,6 +185,13 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => {
title: 'zsh',
hookAgent: null,
processAgent: 'codex',
processProof: {
agent: 'codex',
processIncarnation: 'fixture-codex',
authorityId: 'fixture-authority',
capturedAgeMs: 10,
validForMs: 1_000
},
launchAgent: 'omp'
})
).toBe('codex')
@@ -239,9 +246,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
).toBe('omp')
})
it('ranks the focused idle identity above a hibernated session and launch bootstrap', () => {
// The agent that actually ran and idled here beats both a hibernation record
// and stale launch intent.
it('ranks launch identity above focused completed and hibernated identities', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -252,7 +257,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
sleepingSessionAgent: 'claude',
launchAgent: 'codex'
})
).toBe('omp')
).toBe('codex')
})
it('never lets a title override a live hook (ground truth)', () => {
@@ -267,7 +272,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
).toBe('omp')
})
it('lets a different-group title reclaim a reused idle pane without launch metadata', () => {
it('keeps focused completed identity over a conflicting title without launch metadata', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -277,7 +282,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
focusedCompletedHookAgent: 'codex',
launchAgent: undefined
})
).toBe('claude')
).toBe('codex')
})
it('keeps a launchAgent-less pane with a live Pi hook stable on Pi', () => {
@@ -304,9 +309,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
).toBe('pi')
})
it('keeps a sibling idle identity when the focused pane returns to its shell', () => {
// Focused pane's local shell-exit evidence must not clear the sibling's idle
// identity — the sibling agent is still there.
it('keeps the focused completed identity when the pane returns to its shell', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -317,12 +320,10 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
siblingCompletedHookAgent: 'gemini',
launchAgent: undefined
})
).toBe('gemini')
).toBe('claude')
})
it('does not let a sibling pane re-own the focused pane ambiguous Pi title', () => {
// A split-pane sibling running OMP says nothing about which Pi-variant the
// focused pane runs; the focused pane's own Pi title must stay Pi.
it('uses sole sibling identity when the focused pane has only title evidence', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -333,14 +334,10 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
siblingCompletedHookAgent: 'omp',
launchAgent: undefined
})
).toBe('pi')
).toBe('omp')
})
it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => {
// hasObservedAgentSignal starts false for one mount commit; a completed hook
// is itself activity evidence, so the reuse title reclaims immediately
// instead of flashing the prior agent's idle identity. (claude ran+idled,
// then a hookless codex reused the pane and emits its own title.)
it('keeps completed identity until hookless reuse gains authoritative evidence', () => {
const onMount = resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
isRemote: false,
@@ -357,7 +354,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
focusedCompletedHookAgent: 'claude',
launchAgent: undefined
})
expect(onMount).toBe('codex')
expect(afterObserved).toBe('codex')
expect(onMount).toBe('claude')
expect(afterObserved).toBe('claude')
})
})
@@ -8,6 +8,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { ForegroundProcessProof } from '../../../shared/pane-agent-identity-adapter'
import {
resolveLaunchedAgentExitEvidence,
resolveTabAgentFromSignals
@@ -19,6 +20,13 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
let latestHookAgent: TuiAgent | null | undefined
const hookRoots: Root[] = []
const FRESH_AIDER_PROOF: ForegroundProcessProof = {
agent: 'aider',
processIncarnation: 'fixture-aider',
authorityId: 'fixture-authority',
capturedAgeMs: 10,
validForMs: 1_000
}
function HookProbe({ tab }: { tab: TerminalTab }): null {
latestHookAgent = useTabAgent(tab)
@@ -43,6 +51,35 @@ async function setPaneForeground(entry: PaneForegroundAgentEntry): Promise<void>
}
describe('resolveTabAgentFromSignals process identity', () => {
it('keeps the launched remote identity across a connection blip and restore', () => {
const connected = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '✳ Codex',
hookAgent: 'claude',
processAgent: 'claude',
launchAgent: 'claude'
})
const blip = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '✳ Codex',
hookAgent: null,
processAgent: null,
launchAgent: 'claude'
})
const restored = resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
isRemote: true,
title: '✳ Codex',
hookAgent: null,
processAgent: null,
launchAgent: 'claude'
})
expect([connected, blip, restored]).toEqual(['claude', 'claude', 'claude'])
expect([connected, blip, restored]).not.toContain('codex')
})
it('ranks the recognized foreground process above title and launch bootstrap', () => {
expect(
resolveTabAgentFromSignals({
@@ -51,6 +88,7 @@ describe('resolveTabAgentFromSignals process identity', () => {
title: '✦ Gemini CLI',
hookAgent: null,
processAgent: 'aider',
processProof: FRESH_AIDER_PROOF,
launchAgent: 'codex'
})
).toBe('aider')
@@ -69,9 +107,7 @@ describe('resolveTabAgentFromSignals process identity', () => {
).toBe('claude')
})
it('suppresses launch identity on shell-foreground evidence despite a stale agent title', () => {
// Why: OSC 133;D is process-grade exit proof — a TUI that died without
// restoring its title must not keep painting the tab.
it('keeps launch identity until the lifecycle effect clears shell-exit evidence', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -82,7 +118,7 @@ describe('resolveTabAgentFromSignals process identity', () => {
processShellForeground: true,
launchAgent: 'claude'
})
).toBeNull()
).toBe('claude')
})
it('suppresses stuck-title identity once shell foreground is proven on a manual pane', () => {
@@ -191,7 +227,11 @@ describe('useTabAgent process signals', () => {
await renderHookProbe(baseTab)
expect(latestHookAgent).toBeNull()
await setPaneForeground({ agent: 'aider', shellForeground: false })
await setPaneForeground({
agent: 'aider',
processProof: FRESH_AIDER_PROOF,
shellForeground: false
})
expect(latestHookAgent).toBe('aider')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
@@ -102,7 +102,7 @@ describe('useTabAgent retained completion identity', () => {
useAppStore.setState(initialAppState, true)
})
it('uses focused retained Codex identity over stale Claude launch metadata', async () => {
it('keeps Claude launch metadata ahead of focused retained Codex identity', async () => {
const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID)
useAppStore.setState({
retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') }
@@ -110,7 +110,7 @@ describe('useTabAgent retained completion identity', () => {
await renderProbe()
expect(latestAgent).toBe('codex')
expect(latestAgent).toBe('claude')
})
it('keeps a live focused hook ahead of retained identity', async () => {
@@ -127,7 +127,7 @@ describe('useTabAgent retained completion identity', () => {
expect(latestAgent).toBe('gemini')
})
it('lets an explicit cross-agent title reclaim a retained idle pane', async () => {
it('does not let a cross-agent title replace matching launch and retained identity', async () => {
const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID)
useAppStore.setState({
retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') }
@@ -135,7 +135,7 @@ describe('useTabAgent retained completion identity', () => {
await renderProbe({ ...baseTab, launchAgent: 'codex', title: '✳ Claude Code' })
expect(latestAgent).toBe('claude')
expect(latestAgent).toBe('codex')
})
it('keeps focused launch metadata ahead of sibling retained identity', async () => {
@@ -57,10 +57,7 @@ function sleepingRecord(paneKey: string, agent: ResumableTuiAgent): SleepingAgen
}
describe('resolveTabAgentFromSignals sleeping-session precedence', () => {
it("prefers a hibernated pane's session identity over a stale reused launchAgent", () => {
// Why: a codex launch later reused for claude leaves a claude sleeping record
// for the pane. Its generic spinner title names no agent, so only the session
// record proves the launch identity went stale.
it("prefers launch identity over a conflicting hibernated pane's session", () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -70,7 +67,7 @@ describe('resolveTabAgentFromSignals sleeping-session precedence', () => {
sleepingSessionAgent: 'claude',
launchAgent: 'codex'
})
).toBe('claude')
).toBe('codex')
})
it('keeps live hook identity ahead of a sleeping-session record', () => {
@@ -86,7 +83,7 @@ describe('resolveTabAgentFromSignals sleeping-session precedence', () => {
).toBe('codex')
})
it('keeps current sleeping ownership ahead of an unversioned conflicting title', () => {
it('keeps launch ownership ahead of sleeping-session identity and a conflicting title', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -96,7 +93,7 @@ describe('resolveTabAgentFromSignals sleeping-session precedence', () => {
sleepingSessionAgent: 'gemini',
launchAgent: 'codex'
})
).toBe('gemini')
).toBe('codex')
})
it('keeps a genuine tab icon when its sleeping record matches the launchAgent', () => {
@@ -158,11 +155,8 @@ describe('useTabAgent sleeping-session', () => {
window.api = originalApi
})
it('paints a hibernated pane with its sleeping-session agent over a stale launchAgent', async () => {
it('paints a hibernated pane with its stronger launch identity', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
// Why: the pane was launched as codex, then reused for claude and hibernated.
// No live hook/process remains and the frozen title names no agent, so the
// persisted session record is the only proof the launch identity went stale.
useAppStore.setState({
terminalLayoutsByTabId: {
'tab-1': {
@@ -178,7 +172,7 @@ describe('useTabAgent sleeping-session', () => {
await renderHookProbe({ ...baseTab, title: '⠐ Explain GitHub issue simply' })
expect(latestHookAgent).toBe('claude')
expect(latestHookAgent).toBe('codex')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
})
+19 -23
View File
@@ -105,7 +105,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => {
it('does not promote a title-only OpenClaude marker to a confident tab icon', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
@@ -114,10 +114,10 @@ describe('resolveTabAgentFromSignals', () => {
hookAgent: null,
launchAgent: undefined
})
).toBe('openclaude')
).toBeNull()
})
it('keeps title fallback for real Gemini, MiMo, and Pi titles', () => {
it('keeps anchored Gemini and MiMo titles but rejects an unanchored Pi marker', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
@@ -146,7 +146,7 @@ describe('resolveTabAgentFromSignals', () => {
hookAgent: null,
launchAgent: undefined
})
).toBe('pi')
).toBeNull()
})
it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => {
@@ -248,7 +248,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('openclaude')
})
it('lets an explicit title override stale launch identity after the pane shows newer activity', () => {
it('keeps launch identity after activity when no stronger evidence replaces it', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -257,12 +257,11 @@ describe('resolveTabAgentFromSignals', () => {
hookAgent: null,
launchAgent: 'codex'
})
).toBe('claude')
).toBe('codex')
})
// Why: #8478 — OpenCode native `OC | …` titles must reclaim a stale Claude
// launch identity so the tab icon is OpenCode, not Claude.
it('uses OpenCode native session titles to replace stale Claude launch identity', () => {
// Why: a launch record is a fact Orca owns; an OpenCode title is still a decoration channel.
it('keeps Claude launch identity over a conflicting native OpenCode title', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
@@ -271,7 +270,7 @@ describe('resolveTabAgentFromSignals', () => {
hookAgent: null,
launchAgent: 'claude'
})
).toBe('opencode')
).toBe('claude')
})
// Why: #8940 — an OpenCode session whose task text mentions Claude flipped the tab icon
@@ -296,7 +295,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('opencode')
}
}
// Real pane reuse: the title PRESENTS Claude, so it still reclaims the pane.
// Even a title that presents Claude cannot re-own an OpenCode-launched pane by itself.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -305,7 +304,7 @@ describe('resolveTabAgentFromSignals', () => {
hookAgent: null,
launchAgent: 'opencode'
})
).toBe('claude')
).toBe('opencode')
})
it('does not let an explicit title override launch identity before any activity is observed', () => {
@@ -443,9 +442,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('clears local launch identity once observed activity vanishes at a shell title', () => {
// Why: matches the clear effect — the dropped hook row plus a shell title
// is the crash/kill exit evidence, so the resolver must not lag it.
it('keeps launch identity until the lifecycle effect clears it', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -454,7 +451,7 @@ describe('resolveTabAgentFromSignals', () => {
hookAgent: null,
launchAgent: 'codex'
})
).toBeNull()
).toBe('codex')
})
it('keeps launch identity at a shell title while a sibling hook row is live', () => {
@@ -495,7 +492,7 @@ describe('resolveTabAgentFromSignals', () => {
focusedCompletedHookAgent: 'claude',
launchAgent: 'claude'
})
).toBeNull()
).toBe('claude')
})
it('keeps hook identity for remote panes', () => {
@@ -523,10 +520,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('clears local launch identity once a completed hook and shell title prove exit', () => {
// Why: without foreground probing, a completed hook plus the title back at
// a shell is the process-gone evidence — the same signals that clear the
// sidebar row — so stale launch identity must not keep painting the tab.
it('keeps completed-hook identity above a stale launch record at a shell title', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
@@ -536,7 +530,7 @@ describe('resolveTabAgentFromSignals', () => {
focusedCompletedHookAgent: 'claude',
launchAgent: 'claude'
})
).toBeNull()
).toBe('claude')
})
})
@@ -672,7 +666,9 @@ describe('useTabAgent', () => {
await renderHookProbe({ ...baseTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
expect(latestHookAgent).toBeNull()
// Clearing launch lifecycle evidence leaves the completed hook as the
// canonical display identity for this pane.
expect(latestHookAgent).toBe('codex')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
+13 -163
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { isShellProcess } from '../../../shared/agent-detection'
import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing'
import { hasRemoteRuntimePtyForTab } from './tab-agent-remote-pty-selector'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import type { ForegroundProcessProof } from '../../../shared/pane-agent-identity-adapter'
import {
resolveFocusedCompletedTabAgent,
resolveFocusedRetainedTabAgent,
@@ -12,176 +12,21 @@ import {
resolveSiblingRetainedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
} from '../../../shared/terminal-title-agent-type'
import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner'
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
resolveLaunchedAgentExitEvidence,
resolveTabAgentFromSignals
} from './tab-agent-from-signals'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence.
function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
const trimmed = title.trim()
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
}
/**
* Resolves wrapper-compatible signal identity against the launch owner.
*/
function resolveSignalAgentForLaunchOwner(
signalAgent: TuiAgent | null | undefined,
launchAgent: TuiAgent | null
): TuiAgent | null {
if (!signalAgent) {
return null
}
return (resolveCompatibleAgentTypeForOwner(signalAgent, launchAgent) ?? signalAgent) as TuiAgent
}
/**
* Probe-free evidence a launched agent exited: title shows no agent, no live
* hook remains, and either the hook completed or observed activity vanished.
* Vanished-activity is local-only — remote rows also drop on transport blips.
*/
export function resolveLaunchedAgentExitEvidence(args: {
title: string
defaultTitle?: string
isRemote: boolean
hasObservedAgentSignal: boolean
hookAgent: TuiAgent | null
siblingHookAgent?: TuiAgent | null
hasCompletedHook: boolean
processAgent?: TuiAgent | null
processShellForeground?: boolean
}): boolean {
if (args.hookAgent || args.siblingHookAgent || args.processAgent) {
return false
}
// Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer.
if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) {
return true
}
if (!titleShowsNoAgent(args.title, args.defaultTitle)) {
return false
}
return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal)
}
export function resolveTabAgentFromSignals(args: {
hasObservedAgentSignal: boolean
isRemote: boolean
title: string
defaultTitle?: string
hookAgent: TuiAgent | null
siblingHookAgent?: TuiAgent | null
focusedCompletedHookAgent?: TuiAgent | null
siblingCompletedHookAgent?: TuiAgent | null
processAgent?: TuiAgent | null
processShellForeground?: boolean
sleepingSessionAgent?: TuiAgent | null
launchAgent?: TuiAgent
}): TuiAgent | null {
const launchAgent = args.launchAgent ?? null
// Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP).
const owner = resolvePaneAgentOwner({
launchAgent,
hookAgent: args.hookAgent,
completedHookAgent: args.focusedCompletedHookAgent,
sleepingSessionAgent: args.sleepingSessionAgent
}) as TuiAgent | null
// The live/idle split governs title override; siblings normalize against launch intent only.
const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner)
const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent)
// Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there.
const processProvesShell = !args.isRemote && args.processShellForeground === true
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
const idleIdentitySuppressed =
!args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook
const idleFocusedIdentity = idleIdentitySuppressed
? null
: resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner)
// Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity.
const idleSiblingIdentity = resolveSignalAgentForLaunchOwner(
args.siblingCompletedHookAgent,
launchAgent
)
const sleepingSessionAgent = args.sleepingSessionAgent ?? null
// Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins.
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(
resolveExplicitTerminalTitleAgentType(args.title),
owner
)
const priorIdentity = idleFocusedIdentity ?? launchAgent
const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title)
// Why: a "claude" token in another agent's task text is a mention, not identity, so it must
// not take a pane from its known owner — only a title that PRESENTS Claude may (#8940).
const titleClaimsIdentity =
explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title)
// Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal.
const titleReclaimsReusedPane =
priorIdentity !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== priorIdentity &&
titleClaimsIdentity &&
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle)
// Why: native OpenCode titles lack a provider generation and cannot displace durable ownership.
const titleAgent =
processProvesShell ||
sleepingSessionAgent ||
(nativeOpenCodeTitle && idleFocusedIdentity !== null)
? null
: titleReclaimsReusedPane
? explicitTitleAgent
: priorIdentity
? null
: explicitTitleAgent
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
title: args.title,
defaultTitle: args.defaultTitle,
isRemote: args.isRemote,
hasObservedAgentSignal: args.hasObservedAgentSignal,
hookAgent: liveFocusedIdentity,
siblingHookAgent: liveSiblingIdentity,
hasCompletedHook,
processAgent: args.processAgent,
processShellForeground: args.processShellForeground
})
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
// Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon.
const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner)
// Identity-first precedence (see JSDoc): live hook > process > title > completed > sleeping > launch > sibling.
return (
liveFocusedIdentity ??
processAgent ??
titleAgent ??
idleFocusedIdentity ??
sleepingSessionAgent ??
activeLaunchAgent ??
liveSiblingIdentity ??
idleSiblingIdentity
)
}
/**
* Resolve which coding-harness agent a terminal tab is running, for its tab-bar
* icon. A pane's IDENTITY (separate from activity state), from the same
* already-computed state as the sidebar rows — no foreground probing.
* Identity-first precedence:
*
* 1. Live focused hook — ground truth while the agent works; never title-overridden.
* 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon.
* 3. Title — only a reuse override or legacy standalone identity; native OpenCode titles cannot displace durable ownership.
* 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit.
* 5. Sleeping session identity — current provider-session ownership.
* 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left.
* 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback.
* The resolver's canonical source order is live-hook > process > launch > completed-hook >
* sleeping-session > sibling > title. This hook only gathers the evidence and preserves the
* lifecycle effect that clears a launch record after confirmed local exit.
*/
export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const focusedHookAgent = useAppStore((s) =>
@@ -225,6 +70,9 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const processAgent = useAppStore((s) =>
focusedPaneKey ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.agent ?? null) : null
)
const processProof = useAppStore((s): ForegroundProcessProof | null =>
focusedPaneKey ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.processProof ?? null) : null
)
const processShellForeground = useAppStore((s) =>
focusedPaneKey
? Boolean(s.paneForegroundAgentByPaneKey[focusedPaneKey]?.shellForeground)
@@ -298,6 +146,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
focusedHookAgent,
completedHookEvidence,
processAgent,
processProof,
siblingHookAgent,
tab.launchAgent,
tab.title
@@ -347,6 +196,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
focusedCompletedHookAgent,
siblingCompletedHookAgent,
processAgent,
processProof,
processShellForeground,
sleepingSessionAgent,
launchAgent: tab.launchAgent
@@ -606,22 +606,26 @@ describe('workspace-tab-palette-search', () => {
expect(searchWorkspaceTabs(buildEntries(), query)).toEqual([])
})
it('stamps grok occupancy from the idle OSC title the sidebar already shows', () => {
it('does not stamp grok occupancy from a bare idle OSC title', () => {
const titledOnly = buildEntries({
tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'grok' })] },
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'grok' })] }
})
expect(titledOnly[0]?.occupantAgent).toBe('grok')
expect(searchWorkspaceTabs(titledOnly, 'grok')[0]?.occupantAgent).toBe('grok')
// `grok` is free-text-only evidence; the canonical resolver requires an anchored owner
// suffix (for example `Task - grok`) before a title-only fallback can identify a pane.
expect(titledOnly[0]?.occupantAgent).toBeNull()
expect(searchWorkspaceTabs(titledOnly, 'grok')[0]?.occupantAgent).toBeNull()
})
it('stamps occupancy from the live unified label when the terminal record title is stale', () => {
it('does not stamp occupancy from a bare unified label when the terminal record title is stale', () => {
const staleRecord = buildEntries({
tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'Terminal 1' })] },
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'grok' })] }
})
expect(staleRecord[0]?.title).toBe('grok')
expect(staleRecord[0]?.occupantAgent).toBe('grok')
// `grok` is free-text-only evidence; the canonical resolver requires an anchored owner
// suffix (for example `Task - grok`) before a title-only fallback can identify a pane.
expect(staleRecord[0]?.occupantAgent).toBeNull()
})
it('does not stamp grok occupancy from a hyphenated filename-style title', () => {
@@ -112,7 +112,7 @@ describe('#9040 spinner attribution matches named-provider dot/row agreement', (
title: '⠋ implementing the feature',
launchAgent: 'claude'
} satisfies Partial<TerminalTab>
const namedTab = { id: 'tab-1', title: 'claude [working]' }
const namedTab = { id: 'tab-1', title: '⠋ working - claude' }
expect(getWorktreeStatus([spinnerTab], [], livePtyMap('tab-1'))).toBe('working')
expect(rowCount(spinnerTab)).toBe(1)
+18 -18
View File
@@ -30,8 +30,8 @@ describe('getWorktreeStatus', () => {
it('prioritizes permission over other live activity states', () => {
const status = getWorktreeStatus(
[
{ id: 'tab-1', title: 'claude [working]' },
{ id: 'tab-2', title: 'claude [permission]' }
{ id: 'tab-1', title: '⠋ working - claude' },
{ id: 'tab-2', title: 'Claude - action required' }
],
[{ id: 'browser-1' }],
livePtyMap('tab-1', 'tab-2')
@@ -69,10 +69,10 @@ describe('getWorktreeStatus', () => {
// use-terminal-pane-lifecycle.ts). If the focused pane is idle while
// another pane is still working, the sidebar spinner must stay spinning.
const status = getWorktreeStatus(
[{ id: 'tab-1', title: 'claude [done]' }],
[{ id: 'tab-1', title: 'Claude Code' }],
[],
livePtyMap('tab-1'),
{ 'tab-1': { 0: 'codex [working]', 1: 'claude [done]' } }
{ 'tab-1': { 0: '⠋ working - codex', 1: 'Claude Code' } }
)
expect(status).toBe('working')
@@ -80,10 +80,10 @@ describe('getWorktreeStatus', () => {
it('prefers pane-level permission status over tab.title', () => {
const status = getWorktreeStatus(
[{ id: 'tab-1', title: 'claude [done]' }],
[{ id: 'tab-1', title: 'Claude Code' }],
[],
livePtyMap('tab-1'),
{ 'tab-1': { 0: 'claude [permission]', 1: 'claude [done]' } }
{ 'tab-1': { 0: 'Claude - action required', 1: 'Claude Code' } }
)
expect(status).toBe('permission')
@@ -93,7 +93,7 @@ describe('getWorktreeStatus', () => {
// as a wake-hint sessionId. Reading tab.ptyId for liveness was the bug —
// pin the new behavior so it can't regress.
it('returns inactive for a slept tab (ptyIdsByTabId empty even if heuristic title matches working)', () => {
const status = getWorktreeStatus([{ id: 'tab-1', title: 'claude [working]' }], [], {
const status = getWorktreeStatus([{ id: 'tab-1', title: '⠋ working - claude' }], [], {
'tab-1': []
})
@@ -131,7 +131,7 @@ describe('getWorktreeStatus', () => {
it('still spins on an agent-attributable braille-spinner title', () => {
const status = getWorktreeStatus(
[{ id: 'tab-1', title: '⠹ codex fix flaky test' }],
[{ id: 'tab-1', title: '⠹ fix flaky test - codex' }],
[],
livePtyMap('tab-1')
)
@@ -143,7 +143,7 @@ describe('getWorktreeStatus', () => {
describe('resolveWorktreeStatus', () => {
it('returns inactive when no tab has a live pty and no explicit agent row exists', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [done]' }],
tabs: [{ id: 'tab-1', title: 'Claude Code' }],
browserTabs: [],
// Slept: live-pty array empty; tab.ptyId would be the wake-hint sessionId.
ptyIdsByTabId: { 'tab-1': [] },
@@ -214,7 +214,7 @@ describe('resolveWorktreeStatus', () => {
it('promotes to permission when an explicit agent row needs input, even without a live pty', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [permission]' }],
tabs: [{ id: 'tab-1', title: 'Claude - action required' }],
browserTabs: [],
ptyIdsByTabId: { 'tab-1': [] },
hasPermission: true,
@@ -291,7 +291,7 @@ describe('resolveWorktreeStatus', () => {
it('lets heuristic working beat hasLiveDone (newer in-progress signal wins)', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
tabs: [{ id: 'tab-1', title: '⠋ working - claude' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
hasPermission: false,
@@ -305,12 +305,12 @@ describe('resolveWorktreeStatus', () => {
it('lets a hook-covered done pane suppress its stale working title', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
tabs: [{ id: 'tab-1', title: '⠋ working - claude' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'codex [working]',
1: '⠋ working - codex',
2: 'bash'
}
},
@@ -331,12 +331,12 @@ describe('resolveWorktreeStatus', () => {
it('lets a single hook-covered done pane suppress an unmapped single working title', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
tabs: [{ id: 'tab-1', title: '⠋ working - claude' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'codex [working]'
1: '⠋ working - codex'
}
},
agentStatusPaneIdsByTabId: {
@@ -353,13 +353,13 @@ describe('resolveWorktreeStatus', () => {
it('keeps sibling pane working when hook done covers only another pane', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
tabs: [{ id: 'tab-1', title: '⠋ working - claude' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'bash',
2: 'codex [working]'
2: '⠋ working - codex'
}
},
agentStatusPaneIdsByTabId: {
@@ -384,7 +384,7 @@ describe('resolveWorktreeStatus', () => {
// be silently downgraded to 'done' whenever a separate done overlay exists.
it('honors heuristic permission over hasLiveDone (priority: permission > done)', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [permission]' }],
tabs: [{ id: 'tab-1', title: 'Claude - action required' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
hasPermission: false,
+10 -9
View File
@@ -1,8 +1,8 @@
import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import { resolveCanonicalPaneAgentIdentity } from '../../../shared/pane-agent-identity-adapter'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
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 type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
@@ -112,13 +112,14 @@ function tabHasStatus(
// 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) {
return true
}
// Why: a spinner proves activity but not identity (Claude's thinking title has no provider
// token, #9040); the tab's launch identity supplies it, mirroring the row builder's spinner
// fallback (#9647) so the dot and the sidebar row agree.
return containsAgentSpinnerGlyph(title) && Boolean(launchAgent)
// A title is only an activity hint here; identity attribution comes from the same canonical
// ladder as the tab icon, so a launch record cannot be displaced by a title token.
const identity = resolveCanonicalPaneAgentIdentity({
launchAgent: launchAgent ?? null,
title,
uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true }
})
return identity.agent !== null
}
export function getWorktreeStatusLabel(status: WorktreeStatus): string {
@@ -1,10 +1,13 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { ForegroundProcessProof } from '../../../../shared/pane-agent-identity-adapter'
export type PaneForegroundAgentEntry = {
/** Recognized agent process in the pane's foreground; null when unknown. */
agent: TuiAgent | null
/** Host-stamped process identity; absent reads remain uncovered hints. */
processProof?: ForegroundProcessProof | null
/** True only when fresh provider evidence is safe for input-byte routing. */
routingTrusted?: boolean
/** True after exit/input evidence revokes routing until provider confirmation. */
@@ -46,6 +49,7 @@ export const createPaneForegroundAgentSlice: StateCreator<
if (
current &&
current.agent === entry.agent &&
current.processProof === entry.processProof &&
current.routingTrusted === entry.routingTrusted &&
current.routingRevoked === entry.routingRevoked &&
current.routingConfirmationPending === entry.routingConfirmationPending &&
@@ -74,7 +74,7 @@ describe('clearTransientTerminalState', () => {
})
it('uses "Terminal {index+1}" when customTitle is whitespace only', () => {
const tab = makeTab({ title: '⠋ codex running', customTitle: ' ' })
const tab = makeTab({ title: 'Codex ready', customTitle: ' ' })
const result = clearTransientTerminalState(tab, 0)
expect(result.title).toBe('Terminal 1')
})
@@ -1,5 +1,5 @@
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter'
export function emptyLayoutSnapshot(): TerminalLayoutSnapshot {
return {
@@ -37,5 +37,5 @@ function getResetTitle(tab: TerminalTab, index: number): string {
// Why: reset any recognized agent title on hydration. The prior-session
// agent is no longer running after a restart, so showing a stale
// "Claude done" or spinner would be misleading.
return classifyTitleActivity(tab.title) ? fallbackTitle : tab.title
return resolveCanonicalPaneAgentIdentity({ title: tab.title }).agent ? fallbackTitle : tab.title
}
+5 -1
View File
@@ -4,7 +4,11 @@ export const PANE_AGENT_EVIDENCE_SOURCES = [
'live-hook',
/** The pane's foreground process, as read on the execution host. */
'process',
/** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. */
/** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns.
* Why above completed-hook: completed evidence is newer in principle, but production supplies
* neither completedHookRun nor launchRun and undefined runs remain eligible for mixed-version
* compatibility. A completed hook can therefore describe the previous occupant of a reused
* pane, while launch is scoped to how this pane was set up. Revisit when run keys are wired. */
'launch',
/** A provider hook from a turn that finished. Still authoritative about identity. */
'completed-hook',
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
buildPaneAgentIdentityEvidenceWire,
@@ -136,6 +138,34 @@ describe('uncovered compatibility lane', () => {
).toMatchObject({ agent: null, source: null, coverage: 'uncovered' })
})
it('does not let a legacy title fallback promote a bare free-text name', () => {
expect(
resolveCanonicalPaneAgentIdentity({
title: 'grok',
uncoveredFallback: { agent: 'grok', titleOnly: true }
})
).toMatchObject({
agent: null,
source: null,
coverage: 'uncovered',
titleOnly: false
})
})
it('keeps anchored canonical evidence when the legacy title parser disagrees', () => {
expect(
resolveCanonicalPaneAgentIdentity({
title: '✦ Claude Code',
uncoveredFallback: { agent: 'gemini', titleOnly: true }
})
).toMatchObject({
agent: 'claude',
source: 'title',
coverage: 'uncovered',
titleOnly: true
})
})
it('does not label a foreground-only compatibility answer as title-only', () => {
const identity = resolveCanonicalPaneAgentIdentity({
foregroundAgent: 'codex',
@@ -184,6 +214,29 @@ describe('canonical ladder inside the covered lane', () => {
})
})
describe('launch/completed-hook ordering expiry', () => {
it('fails when production begins supplying run keys so the order is revisited', () => {
const rendererAdapter = [
'src/renderer/src/lib/use-tab-agent.ts',
'src/renderer/src/lib/open-tab-occupant-agent.ts'
]
.map((path) => readFileSync(join(process.cwd(), path), 'utf8'))
.join('\n')
const productionSuppliesRunKeys = /completedHookRun\s*:|launchRun\s*:/.test(rendererAdapter)
if (productionSuppliesRunKeys) {
throw new Error(
'run keys are now supplied; re-evaluate whether completed-hook should outrank launch.'
)
}
expect(
resolveCanonicalPaneAgentIdentity({
launchAgent: 'claude',
completedHookAgent: 'codex'
})
).toMatchObject({ agent: 'claude', source: 'launch' })
})
})
describe('reclaim-versus-stale-hook discriminator (run keys, not title text)', () => {
const run1 = { authorityId: 'main:a', incarnation: 1 }
const run2 = { authorityId: 'main:a', incarnation: 2 }
+23 -16
View File
@@ -115,7 +115,15 @@ export type CanonicalPaneAgentIdentity = {
supersededSources: readonly PaneAgentEvidenceSource[]
}
/** Authority order, strongest first. This is the only place precedence is expressed. */
/**
* Authority order, strongest first. This is the only place precedence is expressed.
*
* Launch intentionally outranks completed-hook while run keys are absent: a completed hook is
* newer evidence in the abstract, but the optional run-key filter currently treats undefined as
* eligible and an unfiltered completed row can belong to a previous occupant of a reused pane;
* launch is scoped to how this pane was set up. Once production supplies `launchRun` and
* `completedHookRun`, revisit this ordering (the expiry test names the required review).
*/
const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES
/** Exported for the source/rank drift ratchet; the rank is the canonical source list itself. */
@@ -232,27 +240,26 @@ export function resolveCanonicalPaneAgentIdentity(
if (!hasAuthorityEvidence) {
if (input.uncoveredFallback) {
const agent = input.uncoveredFallback.agent
// A legacy title parser may have picked the first token from an ambiguous or
// free-text-only title. Do not let that compatibility value bypass the canonical
// ambiguity fence when the caller marks it as title-only evidence.
const rejectTitleFallback =
input.uncoveredFallback.titleOnly === true &&
((titleEvidence?.reason === 'free-text-only' &&
(titleEvidence.freeTextNames?.length ?? 0) > 1) ||
if (input.uncoveredFallback.titleOnly === true) {
// Anchored canonical evidence stands on its own. A marker-only claim retains the legacy
// parser's false-positive guard, while free text can never be promoted by the fallback.
const fallbackConfirmsVendorMarker =
titleEvidence?.reason === 'vendor-marker' && agent !== null && agent === titleAgent
const acceptedTitleAgent =
titleEvidence?.reason === 'anchored' || fallbackConfirmsVendorMarker ? titleAgent : null
const titleIsAmbiguous =
titleEvidence?.reason === 'conflicting-anchored-names' ||
titleEvidence?.reason === 'conflicting-vendor-markers')
if (rejectTitleFallback) {
titleEvidence?.reason === 'conflicting-vendor-markers'
return {
agent: null,
source: null,
agent: acceptedTitleAgent,
source: acceptedTitleAgent === null ? null : 'title',
coverage: 'uncovered',
titleOnly: false,
...(titleEvidence?.reason === 'free-text-only' ? {} : { ambiguousAt: 'title' as const }),
titleOnly: acceptedTitleAgent !== null,
...(titleIsAmbiguous ? { ambiguousAt: 'title' as const } : {}),
supersededSources: []
}
}
const titleOnly =
input.uncoveredFallback.titleOnly ?? (agent !== null && agent === titleAgent)
const titleOnly = agent !== null && agent === titleAgent
return {
agent,
source: agent === null ? null : titleOnly ? 'title' : null,
@@ -28,7 +28,8 @@ const HELPERS = [
] as const
const TEST_SUPPORT_PATHS = new Set([
'src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts'
'src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts',
'src/renderer/src/lib/tab-agent-identity-shipping-baseline.ts'
])
type Helper = (typeof HELPERS)[number]
@@ -150,8 +151,14 @@ const INVENTORY: readonly InventoryGroup[] = [
classification: 'identity-consumer',
paths: [
['mobile/src/session/mobile-terminal-tab-agent.ts', 2],
['src/renderer/src/lib/open-tab-occupant-agent.ts', 2],
['src/renderer/src/lib/use-tab-agent.ts', 3]
['src/renderer/src/components/sidebar/smart-attention-title-status.ts', 2],
['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3],
['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 2],
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
['src/renderer/src/lib/use-tab-agent.ts', 2],
['src/renderer/src/lib/worktree-status.ts', 2]
]
},
{
@@ -159,7 +166,6 @@ const INVENTORY: readonly InventoryGroup[] = [
classification: 'parser-implementation',
paths: [
['src/renderer/src/lib/pane-agent-evidence.ts', 2],
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
'src/shared/terminal-title-agent-type.ts'
]
},
@@ -177,10 +183,7 @@ const INVENTORY: readonly InventoryGroup[] = [
{
helper: 'resolveCommittedTitleAgentType',
classification: 'identity-consumer',
paths: [
['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 4],
['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2]
]
paths: [['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2]]
},
{
helper: 'resolveCommittedTitleAgentType',
@@ -209,8 +212,7 @@ const INVENTORY: readonly InventoryGroup[] = [
classification: 'identity-consumer',
paths: [
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2],
['src/renderer/src/lib/use-tab-agent.ts', 2]
['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2]
]
},
{
@@ -222,11 +224,8 @@ const INVENTORY: readonly InventoryGroup[] = [
helper: 'resolveCompatibleAgentTypeForOwner',
classification: 'identity-consumer',
paths: [
['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
['src/main/runtime/runtime-mobile-session-projection.ts', 3],
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
['src/renderer/src/lib/use-tab-agent.ts', 2]
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2]
]
},
{
@@ -253,12 +252,11 @@ const INVENTORY: readonly InventoryGroup[] = [
helper: 'classifyTitleActivity',
classification: 'identity-consumer',
paths: [
['src/renderer/src/components/sidebar/smart-attention.ts', 3],
['src/renderer/src/components/sidebar/smart-attention-title-status.ts', 2],
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3],
['src/renderer/src/lib/active-agent-note-target.ts', 2],
['src/renderer/src/lib/worktree-status.ts', 3],
['src/renderer/src/store/slices/terminal-helpers.ts', 2]
['src/renderer/src/lib/worktree-status.ts', 3]
]
},
{
@@ -340,11 +338,7 @@ const INVENTORY: readonly InventoryGroup[] = [
{
helper: 'resolveAgentTypeFromTerminalTitle',
classification: 'identity-consumer',
paths: [
['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
'src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts',
['src/renderer/src/lib/worktree-status.ts', 2]
]
paths: ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts']
},
{
helper: 'resolvePaneAgentIdentity',
@@ -364,7 +358,21 @@ const INVENTORY: readonly InventoryGroup[] = [
{
helper: 'resolveCanonicalPaneAgentIdentity',
classification: 'identity-consumer',
paths: [['src/shared/agent-status-identity.ts', 2]]
paths: [
['src/shared/agent-status-identity.ts', 2],
['src/renderer/src/components/sidebar/smart-attention-title-status.ts', 2],
['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3],
['src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts', 3],
['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 2],
['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2],
['src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts', 2],
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
['src/renderer/src/lib/tab-agent-status-index.ts', 2],
['src/renderer/src/lib/worktree-status.ts', 2],
['src/renderer/src/store/slices/terminal-helpers.ts', 2]
]
},
{
helper: 'resolveCanonicalPaneAgentIdentity',