diff --git a/src/shared/pane-agent-identity-resolver.test.ts b/src/shared/pane-agent-identity-resolver.test.ts index f39b0f7a30d..9866c9b96e9 100644 --- a/src/shared/pane-agent-identity-resolver.test.ts +++ b/src/shared/pane-agent-identity-resolver.test.ts @@ -1,145 +1,285 @@ import { describe, expect, it } from 'vitest' import { - PANE_AGENT_EVIDENCE_SOURCES, + PANE_AGENT_DISPLAY_POLICIES, type PaneAgentEvidence, - resolvePaneAgentIdentity + type PaneAgentRun, + resolvePaneAgentDisplayIdentity } from './pane-agent-identity-resolver' -const resolve = (evidence: PaneAgentEvidence[], extra = {}) => - resolvePaneAgentIdentity({ evidence, ...extra }) +const currentRun: PaneAgentRun = { hostAuthorityId: 'host-1', generation: 8 } +const previousRun: PaneAgentRun = { hostAuthorityId: 'host-1', generation: 7 } +const otherHostRun: PaneAgentRun = { hostAuthorityId: 'host-2', generation: 8 } -describe('resolvePaneAgentIdentity', () => { - describe('a display title is the last thing consulted', () => { - it.each(PANE_AGENT_EVIDENCE_SOURCES.filter((s) => s !== 'title' && s !== 'sibling'))( - 'lets %s outrank a conflicting title', +const resolveFocused = (evidence: PaneAgentEvidence[], extra = {}) => + resolvePaneAgentDisplayIdentity({ policy: 'focused-pane', evidence, ...extra }) + +describe('resolvePaneAgentDisplayIdentity', () => { + describe('named display policies', () => { + it.each(PANE_AGENT_DISPLAY_POLICIES['focused-pane'].filter((s) => s !== 'title'))( + 'lets focused-pane %s outrank a conflicting title', (source) => { - const result = resolve([ - { source: 'title', agent: 'codex' }, - { source, agent: 'grok' } - ]) - expect(result.agent).toBe('grok') - expect(result.source).toBe(source) + expect( + resolveFocused([ + { source: 'title', agent: 'codex' }, + { source, agent: 'grok' } + ]) + ).toMatchObject({ kind: 'resolved', agent: 'grok', source }) } ) - it('uses the title only when nothing else is eligible', () => { - expect(resolve([{ source: 'title', agent: 'codex' }])).toMatchObject({ - agent: 'codex', - source: 'title' + it('lets a host-observed process override Orca launch intent', () => { + expect( + resolveFocused([ + { source: 'launch', agent: 'claude' }, + { source: 'process', agent: 'codex' } + ]) + ).toMatchObject({ kind: 'resolved', agent: 'codex', source: 'process' }) + }) + + it('keeps sibling evidence out of focused-pane identity', () => { + expect(resolveFocused([{ source: 'sibling', agent: 'codex' }])).toEqual({ + kind: 'unresolved', + reason: 'no-evidence' }) }) - it('outranks the launch record with nothing weaker than the launch record', () => { - // The tab ladder currently puts the parsed title ABOVE activeLaunchAgent, so a string Orca - // parsed beats a fact Orca owns. That inversion cannot be expressed here. - const result = resolve([ - { source: 'launch', agent: 'claude' }, - { source: 'title', agent: 'gemini' } - ]) - expect(result.agent).toBe('claude') + it('uses sibling only after the active pane title for a tab aggregate', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'tab-aggregate', + evidence: [ + { source: 'sibling', agent: 'codex' }, + { source: 'title', agent: 'grok' } + ] + }) + ).toMatchObject({ kind: 'resolved', agent: 'grok', source: 'title' }) }) }) - describe('run generation separates the bug from the legitimate reclaim', () => { - // Both shapes are `completed hook = A, title = B`. Ordering alone cannot tell them apart. - const shape = (hookRun: number, titleRun: number): PaneAgentEvidence[] => [ - { source: 'completed-hook', agent: 'claude', runId: hookRun }, - { source: 'title', agent: 'codex', runId: titleRun } + describe('host-owned agent-run identity', () => { + const shape = (hookRun: PaneAgentRun, titleRun: PaneAgentRun): PaneAgentEvidence[] => [ + { source: 'completed-hook', agent: 'claude', run: hookRun }, + { source: 'title', agent: 'codex', run: titleRun } ] - it('keeps the completed hook when both belong to the current run', () => { - // The reported bug: nothing new started, so the hook is still the truth. - const result = resolvePaneAgentIdentity({ evidence: shape(7, 7), currentRunId: 7 }) - expect(result).toMatchObject({ agent: 'claude', source: 'completed-hook' }) - expect(result.supersededSources).toEqual([]) - }) - - it('drops the completed hook once a new run has started', () => { - // The legitimate reclaim: the pane was reused, so run 7's hook describes an agent that is - // no longer there. It is ineligible, not merely outranked. - const result = resolvePaneAgentIdentity({ evidence: shape(7, 8), currentRunId: 8 }) - expect(result).toMatchObject({ agent: 'codex', source: 'title' }) - expect(result.supersededSources).toEqual(['completed-hook']) - }) - - it('produces opposite answers from identical evidence, given only the run ids', () => { - // The whole point, stated as one assertion. - const bug = resolvePaneAgentIdentity({ evidence: shape(7, 7), currentRunId: 7 }) - const reclaim = resolvePaneAgentIdentity({ evidence: shape(7, 8), currentRunId: 8 }) - expect(bug.agent).not.toBe(reclaim.agent) - }) - }) - - describe('mixed-version peers', () => { - it('treats evidence with no run id as eligible', () => { - // An old host publishes no run ids. Treating unknown as stale would blank every row. - const result = resolvePaneAgentIdentity({ - evidence: [{ source: 'completed-hook', agent: 'claude' }], - currentRunId: 9 + it('keeps the completed hook when both observations belong to the current run', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: shape(currentRun, currentRun), + currentRun + }) + ).toEqual({ + kind: 'resolved', + agent: 'claude', + source: 'completed-hook', + runScope: 'current' }) - expect(result.agent).toBe('claude') }) - it('disables run filtering entirely when the pane has no current run', () => { - const result = resolvePaneAgentIdentity({ - evidence: [{ source: 'completed-hook', agent: 'claude', runId: 3 }] + it('drops the completed hook after positive evidence advances the agent run', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: shape(previousRun, currentRun), + currentRun + }) + ).toEqual({ + kind: 'resolved', + agent: 'codex', + source: 'title', + runScope: 'current' }) - expect(result).toMatchObject({ agent: 'claude', supersededSources: [] }) + }) + + it('includes host authority rather than comparing naked generation numbers', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: [ + { source: 'completed-hook', agent: 'claude', run: otherHostRun }, + { source: 'title', agent: 'codex', run: currentRun } + ], + currentRun + }) + ).toMatchObject({ kind: 'resolved', agent: 'codex', source: 'title' }) }) }) - describe('siblings are tab-scoped', () => { - it('ignores a sibling by default', () => { - expect(resolve([{ source: 'sibling', agent: 'codex' }]).agent).toBeNull() - }) - - it('accepts a sibling when the caller opts in', () => { - expect(resolve([{ source: 'sibling', agent: 'codex' }], { allowSibling: true }).agent).toBe( - 'codex' - ) - }) - - it('still ranks a sibling above a title', () => { - const result = resolve( - [ - { source: 'title', agent: 'grok' }, - { source: 'sibling', agent: 'codex' } - ], - { allowSibling: true } - ) - expect(result.source).toBe('sibling') - }) - }) - - describe('no eligible evidence', () => { - it('returns null rather than guessing', () => { - expect(resolve([])).toMatchObject({ agent: null, source: null }) - }) - - it('returns null when every source belongs to a superseded run', () => { - const result = resolvePaneAgentIdentity({ - evidence: [ - { source: 'live-hook', agent: 'claude', runId: 1 }, - { source: 'title', agent: 'codex', runId: 1 } - ], - currentRunId: 2 + describe('mixed-version display evidence', () => { + it('preserves source authority when a stronger source cannot publish a run', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: [ + { source: 'live-hook', agent: 'claude' }, + { source: 'title', agent: 'codex', run: currentRun } + ], + currentRun + }) + ).toEqual({ + kind: 'resolved', + agent: 'claude', + source: 'live-hook', + runScope: 'compatibility' }) - expect(result.agent).toBeNull() - expect(result.supersededSources).toEqual(['live-hook', 'title']) + }) + + it('uses exact current-run scope as a tie-breaker within one source', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: [ + { source: 'live-hook', agent: 'claude', run: currentRun }, + { source: 'live-hook', agent: 'codex' } + ], + currentRun + }) + ).toEqual({ + kind: 'resolved', + agent: 'claude', + source: 'live-hook', + runScope: 'current' + }) + }) + + it('disables run filtering when the peer has no current run identity', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: [{ source: 'completed-hook', agent: 'claude', run: previousRun }] + }) + ).toMatchObject({ kind: 'resolved', agent: 'claude', runScope: 'compatibility' }) }) }) - describe('input order does not decide the answer', () => { - it('resolves the same regardless of how evidence is listed', () => { + describe('equal-source ordering', () => { + const conflict: PaneAgentEvidence[] = [ + { source: 'live-hook', agent: 'claude' }, + { source: 'live-hook', agent: 'codex' }, + { source: 'title', agent: 'grok' } + ] + + it('refuses incomparable claims instead of letting a weaker source decide', () => { + expect(resolveFocused(conflict)).toEqual({ + kind: 'unresolved', + reason: 'conflicting-evidence', + source: 'live-hook' + }) + }) + + it('does not let input order break an incomparable tie', () => { + expect(resolveFocused(conflict)).toEqual(resolveFocused(conflict.toReversed())) + }) + + it('chooses the newest observation sequenced by one authority', () => { const evidence: PaneAgentEvidence[] = [ - { source: 'title', agent: 'codex' }, - { source: 'launch', agent: 'grok' }, - { source: 'live-hook', agent: 'claude' } + { + source: 'process', + agent: 'claude', + order: { authorityId: 'main', revision: 4 } + }, + { + source: 'process', + agent: 'codex', + order: { authorityId: 'main', revision: 5 } + } ] - const forward = resolve([...evidence]) - const reverse = resolve(evidence.toReversed()) - expect(forward).toEqual(reverse) - expect(forward.source).toBe('live-hook') + expect(resolveFocused(evidence)).toMatchObject({ + kind: 'resolved', + agent: 'codex', + source: 'process' + }) + expect(resolveFocused(evidence)).toEqual(resolveFocused(evidence.toReversed())) + }) + + it('refuses ordering claims from different authorities', () => { + expect( + resolveFocused([ + { + source: 'process', + agent: 'claude', + order: { authorityId: 'main', revision: 5 } + }, + { + source: 'process', + agent: 'codex', + order: { authorityId: 'renderer', revision: 6 } + } + ]) + ).toMatchObject({ kind: 'unresolved', reason: 'conflicting-evidence' }) + }) + + it('refuses different agents tied at the same authoritative revision', () => { + expect( + resolveFocused([ + { + source: 'live-hook', + agent: 'claude', + order: { authorityId: 'main', revision: 5 } + }, + { + source: 'live-hook', + agent: 'codex', + order: { authorityId: 'main', revision: 5 } + } + ]) + ).toMatchObject({ kind: 'unresolved', reason: 'conflicting-evidence' }) + }) + + it('accepts duplicate observations that agree', () => { + expect( + resolveFocused([ + { source: 'process', agent: 'codex' }, + { source: 'process', agent: 'codex' } + ]) + ).toMatchObject({ kind: 'resolved', agent: 'codex', source: 'process' }) + }) + }) + + describe('unresolved and run-boundary states', () => { + it('returns no-evidence for an empty display input', () => { + expect(resolveFocused([])).toEqual({ kind: 'unresolved', reason: 'no-evidence' }) + }) + + it('returns run-transition when every ranked observation is superseded', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: [ + { source: 'live-hook', agent: 'claude', run: previousRun }, + { source: 'title', agent: 'codex', run: previousRun } + ], + currentRun + }) + ).toEqual({ kind: 'unresolved', reason: 'run-transition' }) + }) + + it('resolves as soon as the positive evidence for the new run is present', () => { + expect( + resolvePaneAgentDisplayIdentity({ + policy: 'focused-pane', + evidence: [ + { source: 'completed-hook', agent: 'claude', run: previousRun }, + { source: 'launch', agent: 'codex', run: currentRun } + ], + currentRun + }) + ).toMatchObject({ kind: 'resolved', agent: 'codex', source: 'launch' }) + }) + }) + + it('does not let input order decide between different sources', () => { + const evidence: PaneAgentEvidence[] = [ + { source: 'title', agent: 'codex' }, + { source: 'launch', agent: 'grok' }, + { source: 'live-hook', agent: 'claude' } + ] + expect(resolveFocused(evidence)).toEqual(resolveFocused(evidence.toReversed())) + expect(resolveFocused(evidence)).toMatchObject({ + kind: 'resolved', + agent: 'claude', + source: 'live-hook' }) }) }) diff --git a/src/shared/pane-agent-identity-resolver.ts b/src/shared/pane-agent-identity-resolver.ts index eddd275a761..8b4268741a1 100644 --- a/src/shared/pane-agent-identity-resolver.ts +++ b/src/shared/pane-agent-identity-resolver.ts @@ -1,109 +1,164 @@ +import type { AgentStatusObservation } from './agent-status-observation' import type { TuiAgent } from './tui-agent' -/** - * One place that answers "which agent is in this pane". - * - * Today four ladders answer it independently — the tab icon, the open-tab/search occupant, the - * sidebar title rows, and the sidebar hook-row fallback — and they disagree. Two of them consult - * the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns. - * - * Two rules make this resolvable where reordering alone could not: - * - * 1. Evidence is ranked by how directly it observes the process, and a display title is last. - * 2. Every observation carries the `runId` of the agent run it belongs to. Evidence from a run - * that has since been replaced is INELIGIBLE rather than merely outranked. - * - * Rule 2 is what separates two situations that are otherwise identical. A completed hook naming - * A plus a title naming B is either a bug (the hook is right, the title is stale) or a legitimate - * reclaim (the pane was reused and B really is there). Same signals, opposite answers. With run - * ids they are different facts: in the bug both belong to the current run, and in the reclaim the - * hook belongs to a previous one. - * - * A run is advanced only on positive evidence that a new agent started in the pane — an accepted - * launch, a recognized command at a shell prompt, a host-confirmed foreground change, a new - * provider session. Never by a title changing, and never by transport loss. - */ -export const PANE_AGENT_EVIDENCE_SOURCES = [ - /** A live provider hook for a turn in progress. The agent is running and said so. */ - '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. */ - 'launch', - /** A provider hook from a turn that finished. Still authoritative about identity. */ - 'completed-hook', - /** A sleeping session record restored for this pane. */ - 'sleeping-session', - /** Another pane in the same tab. Tab-level surfaces only; never pane-scoped routing. */ - 'sibling', - /** Parsed from the terminal title. A decoration channel; anyone can type an agent's name. */ - 'title' -] as const -export type PaneAgentEvidenceSource = (typeof PANE_AGENT_EVIDENCE_SOURCES)[number] +export const PANE_AGENT_DISPLAY_POLICIES = { + 'focused-pane': ['live-hook', 'process', 'launch', 'completed-hook', 'sleeping-session', 'title'], + 'tab-aggregate': [ + 'live-hook', + 'process', + 'launch', + 'completed-hook', + 'sleeping-session', + 'title', + 'sibling' + ] +} as const -/** Authority order, strongest first. Position here is the ONLY place precedence is expressed. */ -const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES +export type PaneAgentDisplayPolicy = keyof typeof PANE_AGENT_DISPLAY_POLICIES +export type PaneAgentEvidenceSource = + (typeof PANE_AGENT_DISPLAY_POLICIES)[PaneAgentDisplayPolicy][number] + +/** Execution-host-owned identity of one agent run behind a pane key. */ +export type PaneAgentRun = { + hostAuthorityId: string + generation: number +} + +/** Comparable only when both observations carry the same authority id. */ +export type PaneAgentEvidenceOrder = Readonly< + Pick +> export type PaneAgentEvidence = { source: PaneAgentEvidenceSource agent: TuiAgent - /** - * The agent run this evidence describes. Evidence whose run is not the pane's current run is - * ineligible. Undefined means unknown — from an old peer that does not publish run ids, or an - * ingress not yet stamped — and is treated as eligible, so a missing field never blanks a row. - */ - runId?: number + /** Absent when an older peer or unstamped ingress cannot identify the agent run. */ + run?: PaneAgentRun + /** Used only to settle equal-source claims that one authority sequenced. */ + order?: PaneAgentEvidenceOrder } -export type PaneAgentIdentityInput = { +export type PaneAgentDisplayIdentityInput = { + policy: PaneAgentDisplayPolicy evidence: readonly PaneAgentEvidence[] - /** The pane's current run. Undefined disables run filtering entirely (old peer, mixed version). */ - currentRunId?: number - /** - * Pane-scoped consumers must not inherit another pane's agent. Sibling evidence is dropped - * unless the caller is a tab-level surface that opted in. - */ - allowSibling?: boolean + /** Absent on older peers; display resolution then preserves compatibility. */ + currentRun?: PaneAgentRun } -export type PaneAgentIdentity = { - agent: TuiAgent | null - /** Which class of evidence decided it. Null when nothing eligible remained. */ - source: PaneAgentEvidenceSource | null - /** Evidence discarded because it belongs to a superseded run. Surfaced for diagnostics. */ - supersededSources: readonly PaneAgentEvidenceSource[] +export type ResolvedPaneAgentDisplayIdentity = { + kind: 'resolved' + agent: TuiAgent + source: PaneAgentEvidenceSource + /** Compatibility means one side cannot publish agent-run identity. */ + runScope: 'current' | 'compatibility' } +export type UnresolvedPaneAgentDisplayIdentity = { + kind: 'unresolved' + reason: 'no-evidence' | 'run-transition' | 'conflicting-evidence' + /** Present when equally ranked, incomparable evidence names different agents. */ + source?: PaneAgentEvidenceSource +} + +export type PaneAgentDisplayIdentity = + | ResolvedPaneAgentDisplayIdentity + | UnresolvedPaneAgentDisplayIdentity + /** - * Resolves one pane's agent from ranked evidence. - * - * Returns null rather than guessing. A pane with no eligible evidence shows no agent, which is - * recoverable; showing the wrong agent is not, and at the action surfaces (orchestration routing, - * mailbox delivery, prompt-cache timers) it is a misdelivery rather than a cosmetic slip. + * Display-only identity resolution. Write authorization requires proof-bearing process, hook, or + * launch capabilities and deliberately has no resolver in this module. */ -export function resolvePaneAgentIdentity(input: PaneAgentIdentityInput): PaneAgentIdentity { - const superseded: PaneAgentEvidenceSource[] = [] - const eligible = input.evidence.filter((item) => { - if (item.source === 'sibling' && input.allowSibling !== true) { - return false - } - // Why undefined is eligible: absence means "this peer does not publish run ids", not "this - // belongs to an old run". Treating unknown as stale would blank every row from an old host. - if (input.currentRunId === undefined || item.runId === undefined) { - return true - } - if (item.runId === input.currentRunId) { - return true - } - superseded.push(item.source) - return false - }) +export function resolvePaneAgentDisplayIdentity( + input: PaneAgentDisplayIdentityInput +): PaneAgentDisplayIdentity { + const sources = PANE_AGENT_DISPLAY_POLICIES[input.policy] + let rejectedSupersededRun = false - for (const source of SOURCE_RANK) { - const match = eligible.find((item) => item.source === source) - if (match) { - return { agent: match.agent, source, supersededSources: superseded } + for (const source of sources) { + const eligible: PaneAgentEvidence[] = [] + for (const item of input.evidence) { + if (item.source !== source) { + continue + } + if (isSupersededRun(item.run, input.currentRun)) { + rejectedSupersededRun = true + continue + } + eligible.push(item) + } + if (eligible.length === 0) { + continue + } + + const exactCurrent = eligible.filter((item) => isSameRun(item.run, input.currentRun)) + const candidates = exactCurrent.length > 0 ? exactCurrent : eligible + const selected = selectEqualSourceCandidate(candidates) + if (selected === null) { + return { kind: 'unresolved', reason: 'conflicting-evidence', source } + } + return { + kind: 'resolved', + agent: selected.agent, + source, + runScope: isSameRun(selected.run, input.currentRun) ? 'current' : 'compatibility' } } - return { agent: null, source: null, supersededSources: superseded } + + return { + kind: 'unresolved', + reason: rejectedSupersededRun ? 'run-transition' : 'no-evidence' + } +} + +function selectEqualSourceCandidate( + candidates: readonly PaneAgentEvidence[] +): PaneAgentEvidence | null { + const first = candidates[0] + if (candidates.every((item) => item.agent === first.agent)) { + return first + } + + const firstOrder = first.order + if ( + firstOrder === undefined || + candidates.some( + (item) => item.order === undefined || item.order.authorityId !== firstOrder.authorityId + ) + ) { + return null + } + + let newest = first + for (const candidate of candidates.slice(1)) { + if ( + candidate.order !== undefined && + newest.order !== undefined && + candidate.order.revision > newest.order.revision + ) { + newest = candidate + } + } + if (newest.order === undefined) { + return null + } + const sameRevision = candidates.filter((item) => item.order?.revision === newest.order?.revision) + return sameRevision.every((item) => item.agent === newest.agent) ? newest : null +} + +function isSupersededRun( + evidenceRun: PaneAgentRun | undefined, + currentRun: PaneAgentRun | undefined +): boolean { + return ( + evidenceRun !== undefined && currentRun !== undefined && !isSameRun(evidenceRun, currentRun) + ) +} + +function isSameRun(left: PaneAgentRun | undefined, right: PaneAgentRun | undefined): boolean { + return ( + left !== undefined && + right !== undefined && + left.hostAuthorityId === right.hostAuthorityId && + left.generation === right.generation + ) }