Separate pane identity from liveness in the tab-agent resolver (fixes OMP tab flicker) (#7860)

This commit is contained in:
Brennan Benson 2026-07-09 11:38:26 -07:00 committed by GitHub
parent 81e4f0fa68
commit 1534edc073
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 527 additions and 152 deletions

View File

@ -165,6 +165,7 @@ import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import {
isExpectedAgentProcess,
@ -1497,11 +1498,13 @@ export function connectPanePty(
(entry) => entry.id === deps.tabId
)
return (
tab?.launchAgent ??
paneStartup?.launchAgent ??
paneStartup?.initialAgentStatus?.agent ??
commandInferredPaneAgent ??
state.agentStatusByPaneKey[cacheKey]?.agentType
resolvePaneAgentOwner({
launchAgent: tab?.launchAgent,
startupLaunchAgent: paneStartup?.launchAgent,
initialStatusAgent: paneStartup?.initialAgentStatus?.agent,
commandInferredAgent: commandInferredPaneAgent,
hookAgent: state.agentStatusByPaneKey[cacheKey]?.agentType
}) ?? undefined
)
}
// Why: the renderer veto (owner evidence beating a Gemini-looking title) must

View File

@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest'
import { resolveTabAgentFromSignals } from './use-tab-agent'
// Pi/OMP share a title-identity group: OMP wraps Pi and emits Pi-compatible
// wrapper title frames. These tests pin how the tab-icon resolver keeps an
// OMP-owned pane on OMP (and a Pi-owned pane on Pi) as those frames arrive,
// including when the pane loses its host-owned launchAgent on a mirrored or
// restored client.
describe('resolveTabAgentFromSignals — Pi/OMP identity', () => {
it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal 1',
hookAgent: null,
siblingHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ OMP',
hookAgent: 'omp',
launchAgent: 'pi'
})
).toBe('pi')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'zsh',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
siblingCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
})
it('keeps a restored/mirrored OMP pane on OMP when launchAgent is gone', () => {
// Why: a mirrored or restored OMP pane loses its host-owned launchAgent but
// keeps emitting Pi-compatible wrapper title frames. Durable pane identity
// (last completed hook / hibernated session) must anchor those frames to OMP
// instead of letting them repaint the tab as Pi.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
sleepingSessionAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
})
it('does not flap between OMP and Pi as a launchAgent-less pane cycles hooks', () => {
// The flicker: identity must not flip when the live hook row appears/clears.
const withLiveHook = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'omp',
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
const afterHookCleared = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
expect(withLiveHook).toBe('omp')
expect(afterHookCleared).toBe('omp')
})
it('keeps a launchAgent-less Pi pane on Pi and rejects a stale OMP session record', () => {
// The fallback must not over-reach: a genuine Pi pane (recent Pi hook) stays
// Pi even if a stale hibernated OMP record is present.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
sleepingSessionAgent: 'omp',
launchAgent: undefined
})
).toBe('pi')
// An OMP-compatible title on a launchAgent-less Pi pane still resolves to Pi.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ OMP',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: undefined
})
).toBe('pi')
})
})
// The tab icon is a pane's IDENTITY, not its activity state: a hook record
// identifies the pane whether the agent is mid-turn (live) or idle (done). These
// pin that separation so identity can't collapse back into the (non-
// distinguishing) title layer.
describe('resolveTabAgentFromSignals — identity vs liveness', () => {
it('surfaces the focused idle identity from the record, not the title', () => {
// Agent went idle between turns; the title names no agent. Identity still
// comes from the pane's own done-hook record.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
).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.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
sleepingSessionAgent: 'claude',
launchAgent: 'codex'
})
).toBe('omp')
})
it('never lets a title override a live hook (ground truth)', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '✳ Claude Code',
hookAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
})
it('lets a different-group title reclaim a reused idle pane without launch metadata', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '✳ Claude Code',
hookAgent: null,
focusedCompletedHookAgent: 'codex',
launchAgent: undefined
})
).toBe('claude')
})
it('keeps a launchAgent-less pane with a live Pi hook stable on Pi', () => {
// A launchless pane whose live hook reports Pi resolves to Pi and stays Pi
// when the hook clears (the completed record is Pi too) — no flip to OMP.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: undefined
})
).toBe('pi')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: undefined
})
).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.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
siblingCompletedHookAgent: 'gemini',
launchAgent: undefined
})
).toBe('gemini')
})
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.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: null,
siblingCompletedHookAgent: 'omp',
launchAgent: undefined
})
).toBe('pi')
})
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.)
const onMount = resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
isRemote: false,
title: '⠋ Codex',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
launchAgent: undefined
})
const afterObserved = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '⠋ Codex',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
launchAgent: undefined
})
expect(onMount).toBe('codex')
expect(afterObserved).toBe('codex')
})
})

View File

@ -271,80 +271,8 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal 1',
hookAgent: null,
siblingHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ OMP',
hookAgent: 'omp',
launchAgent: 'pi'
})
).toBe('pi')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'zsh',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
siblingCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
})
// Pi/OMP identity (shared title-identity group, launchAgent-loss flicker)
// lives in use-tab-agent-pi-identity.test.ts.
it('prefers explicit hook identity over a conflicting title mention', () => {
expect(

View File

@ -12,6 +12,7 @@ import {
} from './tab-agent'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
import type { TerminalTab, TuiAgent } from '../../../shared/types'
// A shell name, or the tab's neutral default title — where Orca's
@ -83,50 +84,85 @@ export function resolveTabAgentFromSignals(args: {
launchAgent?: TuiAgent
}): TuiAgent | null {
const launchAgent = args.launchAgent ?? null
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(
resolveExplicitTerminalTitleAgentType(args.title),
launchAgent
)
// Why: explicit titles can override stale launches after activity, but
// Pi-compatible wrapper signals first resolve through the launch owner so
// OMP-created sessions do not repaint as Pi.
// Why: OSC 133;D proved this local pane's foreground is back at the shell,
// so any title-derived identity is stale by definition — a TUI that died
// with a stuck title must not keep painting the tab through the title layer.
// The focused pane's durable agent owner: launch intent first, then this
// pane's own host-stamped hook identity (live or completed), then its
// hibernated session record. It anchors every identity decision for THIS pane
// — it re-owns ambiguous Pi-compatible wrapper titles (OMP emits Pi's frames)
// and keeps a mirrored or restored pane, which dropped its host-owned
// launchAgent, resolving through the durable record instead of the title.
// Why: strictly focused-pane-scoped — a sibling split-pane's identity says
// nothing about which Pi-variant this pane runs, so it must not re-own the
// focused title (it would mislabel a genuine Pi pane as its sibling's OMP).
const owner = resolvePaneAgentOwner({
launchAgent,
hookAgent: args.hookAgent,
completedHookAgent: args.focusedCompletedHookAgent,
sleepingSessionAgent: args.sleepingSessionAgent
}) as TuiAgent | null
// A pane's identity comes from its hook record regardless of activity state —
// being idle between turns must not erase which agent is here. We keep the
// live/idle split only because it governs how a title may override identity: a
// LIVE hook is ground truth (a title never overrides it), while an IDLE record
// is durable but a cross-group title can reclaim a pane reused for a different
// agent. Sibling identities normalize against launchAgent only (the tab's
// shared launch intent), never the focused pane's own hook identity.
const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner)
const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent)
// Why: OSC 133;D proved this local pane's foreground is back at the shell, so
// the finished agent's idle identity is stale and must stop painting the tab.
// Remote titles lag their runtime, so keep the idle identity there.
const processProvesShell = !args.isRemote && args.processShellForeground === true
const titleOverridesLaunch =
launchAgent !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== launchAgent &&
args.hasObservedAgentSignal
const titleAgent = processProvesShell
? null
: titleOverridesLaunch
? explicitTitleAgent
: launchAgent
? null
: explicitTitleAgent
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
// Why: remote pane titles can lag their runtime, so keep the last completed
// hook identity instead of flashing unknown when the title reads as a shell.
const completedHookAgent =
const idleIdentitySuppressed =
!args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook
? null
: resolveSignalAgentForLaunchOwner(
args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent,
launchAgent
)
const focusedHookAgent = resolveSignalAgentForLaunchOwner(args.hookAgent, launchAgent)
const siblingHookAgent = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent)
const fallbackHookAgent = siblingHookAgent ?? completedHookAgent
const idleFocusedIdentity = idleIdentitySuppressed
? null
: resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner)
// Why: `idleIdentitySuppressed` is the FOCUSED pane's own exit evidence, so it
// must not clear a sibling split-pane's idle identity — a focused pane back at
// its shell says nothing about whether the sibling's agent has exited.
const idleSiblingIdentity = resolveSignalAgentForLaunchOwner(
args.siblingCompletedHookAgent,
launchAgent
)
// The title carries identity in only two roles: (a) a reuse override — it
// names a DIFFERENT-group agent than the pane's known identity, proving the
// pane was reused for a new agent — or (b) a legacy standalone identity when
// the pane has no hook at all. Within the same title-identity group it says
// nothing (OMP wraps Pi and emits identical frames), so the durable record
// wins; re-owning explicitTitleAgent through `owner` enforces that.
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(
resolveExplicitTerminalTitleAgentType(args.title),
owner
)
const priorIdentity = idleFocusedIdentity ?? launchAgent
// Why: a completed hook is itself proof the pane has shown activity, so it
// arms the reuse override without waiting for `hasObservedAgentSignal` — which
// starts false for one mount commit and would otherwise flash the exited
// agent's idle identity before the new (hookless) agent's title reclaims.
const titleReclaimsReusedPane =
priorIdentity !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== priorIdentity &&
(args.hasObservedAgentSignal || hasCompletedHook)
const titleAgent = processProvesShell
? null
: titleReclaimsReusedPane
? explicitTitleAgent
: priorIdentity
? null
: explicitTitleAgent
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
title: args.title,
defaultTitle: args.defaultTitle,
isRemote: args.isRemote,
hasObservedAgentSignal: args.hasObservedAgentSignal,
hookAgent: focusedHookAgent,
siblingHookAgent,
hookAgent: liveFocusedIdentity,
siblingHookAgent: liveSiblingIdentity,
hasCompletedHook,
processAgent: args.processAgent,
processShellForeground: args.processShellForeground
@ -134,53 +170,48 @@ export function resolveTabAgentFromSignals(args: {
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
const processAgent = args.processAgent ?? null
const sleepingSessionAgent = args.sleepingSessionAgent ?? null
// Why: titleAgent ranks ahead of launch/fallback hooks because, once the
// pane has shown activity, a live explicit title is the freshest identity
// signal — it beats a launchAgent gone stale through pane reuse. Before any
// activity, titleAgent is null while launchAgent exists, so launch bootstrap
// still wins the startup window. Process identity (the recognized foreground
// process) is ground truth below hooks — it covers agents that emit neither
// hooks nor titles. sleepingSessionAgent ranks above launch bootstrap: a
// hibernated pane's captured session identity is pane-scoped proof that the
// launch identity went stale (a codex launch later reused for claude), and it
// is the only live signal while the pane is asleep (its PTY, hooks, and
// process are all gone).
// Identity-first precedence. The live focused hook is ground truth while the
// agent works; process identity covers agents with neither hook nor title; the
// title then acts only in its reuse-override / legacy roles; and the pane's
// durable idle identity — the record for an agent that ran here and went idle —
// ranks above the hibernated session, the launch bootstrap, and sibling panes.
return (
focusedHookAgent ??
liveFocusedIdentity ??
processAgent ??
titleAgent ??
idleFocusedIdentity ??
sleepingSessionAgent ??
activeLaunchAgent ??
fallbackHookAgent
liveSiblingIdentity ??
idleSiblingIdentity
)
}
/**
* Resolve which coding-harness agent a terminal tab is running, for its tab-bar
* icon. Identity flows through the same already-computed state as the sidebar
* agent rows no foreground probing. Layered signals, most-authoritative
* first:
* agent rows no foreground probing. It is a pane's IDENTITY, kept separate
* from its activity state: a hook record identifies the pane whether the agent
* is working or idle. Identity-first precedence:
*
* 1. Hook status provider identity from native integrations; the live entry
* for the pane, dropped by the same OSC 133 command-finished machinery that
* clears the sidebar row when the process exits.
* 1. Live focused hook provider identity from native integrations while the
* agent is actively working; ground truth, never overridden by a title.
* 2. Process identity the recognized foreground process, read at OSC 133
* command boundaries (local panes only); covers agents that emit neither
* hooks nor titles, and its shell-foreground mark is title-independent
* exit evidence.
* 3. Sleeping session identity a hibernated pane's captured session record
* (agent + provider session). Ranks below title but above launchAgent
* because it is pane-scoped proof of reuse: a codex launch later reused for
* claude leaves a claude sleeping record, and while the pane is asleep this
* is the only live identity signal (its PTY, hooks, and process are gone).
* 4. launchAgent what Orca launched here; instant bootstrap before hooks
* arrive, cleared once hook/process/title evidence shows the launched
* agent exited.
* 5. Title legacy/unknown-session fallback, and the live override when a pane
* is reused: once the pane has shown activity, a title that explicitly names
* a different agent than launchAgent wins over that stale launch identity.
* Otherwise it is ignored while launchAgent exists, and generic spinner-only
* titles never identify an agent.
* hooks nor titles, and its shell-foreground mark is title-independent exit
* evidence.
* 3. Title only as a reuse override (it names a DIFFERENT-group agent than the
* pane's known identity, proving reuse) or as a legacy standalone identity
* when the pane has no hook. Within the same title-identity group it carries
* no identity (OMP wraps Pi with identical frames), so the record wins.
* 4. Idle focused identity the pane's own hook record after the agent went
* idle between turns; the durable answer to "which agent is this" once the
* live hook, process, and any reuse-title are absent. Suppressed on a local
* pane once OSC 133;D proves the agent exited.
* 5. Sleeping session identity a hibernated pane's captured session record.
* 6. launchAgent what Orca launched here; the bootstrap before any hook, hook
* record, or process signal exists, cleared once exit evidence shows it left.
* 7. Sibling-pane identity (live, then idle) split-tab fallback.
*/
export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const focusedHookAgent = useAppStore((s) =>

View File

@ -48,6 +48,7 @@ import {
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner
} from '../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
@ -542,10 +543,12 @@ function buildMirroredTerminalTabs(
.filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0)
const launchAgent =
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
const ownerAgent =
launchAgent ??
activeSurface.agentStatus?.agentType ??
surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus?.agentType
const ownerAgent = resolvePaneAgentOwner({
launchAgent,
hookAgent: activeSurface.agentStatus?.agentType,
siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus
?.agentType
})
const title = normalizeCompatibleAgentTitleForOwner(
activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal',
ownerAgent
@ -624,7 +627,10 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null
if (!paneKey) {
return null
}
const ownerAgent = surface.launchAgent ?? surface.agentStatus.agentType
const ownerAgent = resolvePaneAgentOwner({
launchAgent: surface.launchAgent,
hookAgent: surface.agentStatus.agentType
})
return {
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
paneKey

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { resolvePaneAgentOwner } from './pane-agent-owner'
describe('resolvePaneAgentOwner', () => {
it('leads with launch intent', () => {
expect(
resolvePaneAgentOwner({ launchAgent: 'omp', hookAgent: 'pi', sleepingSessionAgent: 'claude' })
).toBe('omp')
expect(resolvePaneAgentOwner({ startupLaunchAgent: 'codex', hookAgent: 'claude' })).toBe(
'codex'
)
expect(
resolvePaneAgentOwner({ initialStatusAgent: 'gemini', commandInferredAgent: 'codex' })
).toBe('gemini')
})
it('falls through to the durable host-stamped hook identity when launch intent is gone', () => {
// The mirror/restore case: launchAgent dropped, live hook is the anchor.
expect(resolvePaneAgentOwner({ hookAgent: 'omp' })).toBe('omp')
// Live hook cleared: the last completed hook carries the identity.
expect(resolvePaneAgentOwner({ completedHookAgent: 'omp' })).toBe('omp')
// Nothing live at all: the hibernated session record is the last resort.
expect(resolvePaneAgentOwner({ sleepingSessionAgent: 'omp' })).toBe('omp')
})
it('ranks live/recent evidence above the hibernated record so a stale record cannot hijack', () => {
expect(resolvePaneAgentOwner({ hookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe('pi')
expect(resolvePaneAgentOwner({ completedHookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe(
'pi'
)
})
it('prefers focused over sibling evidence at each tier', () => {
expect(resolvePaneAgentOwner({ hookAgent: 'omp', siblingHookAgent: 'pi' })).toBe('omp')
expect(
resolvePaneAgentOwner({ completedHookAgent: 'omp', siblingCompletedHookAgent: 'pi' })
).toBe('omp')
})
it('returns null when no owner evidence exists', () => {
expect(resolvePaneAgentOwner({})).toBeNull()
expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull()
})
})

View File

@ -0,0 +1,58 @@
import type { AgentType } from './agent-status-types'
/**
* The owner-evidence signals a terminal pane can carry, strongest launch intent
* first, ending in the durable host-stamped identity. Every field is optional so
* each call site passes only what it holds; absent signals are skipped.
*/
export type PaneAgentOwnerSignals = {
/** Tab-scoped launch intent — what Orca launched into this tab. */
launchAgent?: AgentType | null
/** Never-cleared per-connection launch seed (pane connection only). */
startupLaunchAgent?: AgentType | null
/** Startup-provided initial agent status (pane connection only). */
initialStatusAgent?: AgentType | null
/** Agent inferred from a manually typed shell command (pane connection only). */
commandInferredAgent?: AgentType | null
/** Live focused-pane hook identity — host-stamped, published, mirror-safe. */
hookAgent?: AgentType | null
/** Live sibling-pane hook identity. */
siblingHookAgent?: AgentType | null
/** Last completed focused-pane hook — survives the live hook row clearing. */
completedHookAgent?: AgentType | null
/** Last completed sibling-pane hook. */
siblingCompletedHookAgent?: AgentType | null
/** Hibernated session record — the final durable identity while a pane sleeps. */
sleepingSessionAgent?: AgentType | null
}
/**
* The single authoritative resolver for "which agent owns this pane", shared by
* the tab-icon resolver, the terminal-pane display/renderer owner, and the
* mirrored-tab title owner so they cannot drift apart.
*
* Why this precedence: launch intent is the authoritative bootstrap before any
* process signal exists, so it leads. Once launch metadata is gone a mirrored
* or restored pane drops the host-owned launchAgent the owner must fall
* through to a durable pane identity rather than to the raw title, because a
* wrapper agent's title (OMP emits Pi-compatible frames) cannot be told apart
* from the agent it wraps. The host-stamped hook identity is that durable,
* published, mirror-safe anchor; the last completed hook and the hibernated
* session record carry it across the windows where no live hook exists. Ranking
* launch/live-hook above the completed/sleeping records keeps a genuine pane on
* its real agent and stops a stale record from hijacking it.
*/
export function resolvePaneAgentOwner(signals: PaneAgentOwnerSignals): AgentType | null {
return (
signals.launchAgent ??
signals.startupLaunchAgent ??
signals.initialStatusAgent ??
signals.commandInferredAgent ??
signals.hookAgent ??
signals.siblingHookAgent ??
signals.completedHookAgent ??
signals.siblingCompletedHookAgent ??
signals.sleepingSessionAgent ??
null
)
}