Separate pane identity from liveness in the tab-agent resolver (fixes OMP tab flicker) (#7860)
This commit is contained in:
parent
81e4f0fa68
commit
1534edc073
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue