mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user