diff --git a/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts b/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts new file mode 100644 index 00000000000..6c543db3594 --- /dev/null +++ b/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts @@ -0,0 +1,198 @@ +import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + resolveCanonicalPaneAgentIdentity, + type CanonicalPaneAgentIdentity +} from '../../../shared/pane-agent-identity-adapter' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' +import type { TuiAgent } from '../../../shared/tui-agent' + +const AGENTS: readonly TuiAgent[] = ['claude', 'codex'] +const SLOT_COUNT = 7 +const SHAPE_COUNT = 3 ** SLOT_COUNT * 4 * 2 +const TITLES: readonly string[] = ['', 'zsh', 'Task - claude', 'Task - codex'] + +type Breakdown = Record< + 'launch' | 'completed-hook' | 'sleeping-session' | 'process' | 'sibling' | 'title', + number +> + +function slotValues(mask: number): (TuiAgent | null)[] { + let remaining = mask + return Array.from({ length: SLOT_COUNT }, () => { + const value = remaining % 3 + remaining = Math.floor(remaining / 3) + return value === 0 ? null : AGENTS[value - 1] + }) +} + +function canonicalResult( + values: readonly (TuiAgent | null)[], + title: string, + withProof: boolean +): CanonicalPaneAgentIdentity { + const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] = values + return resolveCanonicalPaneAgentIdentity({ + hookAgent: hook, + hookIsLive: hook !== null, + completedHookAgent: completed, + launchAgent: launch, + foregroundAgent: process, + processProof: + withProof && process + ? { + agent: process, + processIncarnation: 'fixture-process', + authorityId: 'fixture-authority', + capturedAgeMs: 10, + validForMs: 1_000 + } + : undefined, + sleepingSessionAgent: sleeping, + siblingAgents: [siblingHook, siblingCompleted].filter( + (agent): agent is TuiAgent => agent !== null + ), + allowSibling: true, + title + }) +} + +function realResult(values: readonly (TuiAgent | null)[], title: string, remote: boolean) { + 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({ + hasObservedAgentSignal: true, + isRemote: remote, + title, + hookAgent: hook, + siblingHookAgent: siblingHook, + focusedCompletedHookAgent: completed, + siblingCompletedHookAgent: siblingCompleted, + processAgent: process, + processShellForeground: false, + sleepingSessionAgent: sleeping, + launchAgent: launch ?? undefined + }) +} + +function runDecisionTable(withProof: boolean) { + let disagreements = 0 + let flipped = 0 + const breakdown: Breakdown = { + launch: 0, + 'completed-hook': 0, + 'sleeping-session': 0, + process: 0, + sibling: 0, + title: 0 + } + for (let mask = 0; mask < 3 ** SLOT_COUNT; mask += 1) { + const values = slotValues(mask) + for (const title of TITLES) { + for (const remote of [false, true]) { + const real = realResult(values, title, remote) + const canonical = canonicalResult(values, title, withProof) + if (real !== canonical.agent) { + disagreements += 1 + if (canonical.source !== null) { + breakdown[canonical.source] += 1 + } + } + if (!withProof) { + const proven = canonicalResult(values, title, true) + if ( + canonical.agent !== proven.agent && + proven.source === 'process' && + (canonical.source === 'launch' || + canonical.source === 'completed-hook' || + canonical.source === 'sleeping-session') + ) { + flipped += 1 + } + } + } + } + } + return { disagreements, flipped, breakdown } +} + +describe('renderer ladder decision table', () => { + it('replays the real shipping ladder and records all rung disagreements', () => { + const proofFree = runDecisionTable(false) + const freshProof = runDecisionTable(true) + const result = { + shapes: SHAPE_COUNT, + proofOmitted: proofFree, + freshProof, + flippedByAddingProof: proofFree.flipped + } + 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({ + disagreements: 2_622, + flipped: 1_872, + breakdown: { + launch: 1_884, + 'completed-hook': 478, + 'sleeping-session': 144, + process: 0, + sibling: 54, + title: 6 + } + }) + expect(freshProof).toEqual({ + disagreements: 658, + flipped: 0, + breakdown: { + launch: 588, + 'completed-hook': 46, + 'sleeping-session': 0, + process: 0, + sibling: 6, + title: 2 + } + }) + expect(proofFree.flipped).toBe(1_872) + }) + + it('requires both freshness fields before process evidence can change the no-proof result', () => { + const values = [null, null, null, null, 'codex', null, 'claude'] as const + expect(canonicalResult(values, '', false)).toMatchObject({ + agent: 'claude', + source: 'launch' + }) + expect( + resolveCanonicalPaneAgentIdentity({ + foregroundAgent: 'codex', + processProof: { + agent: 'codex', + processIncarnation: 'fixture-process', + authorityId: 'fixture-authority', + capturedAgeMs: undefined as unknown as number, + validForMs: 1_000 + }, + launchAgent: 'claude' + }) + ).toMatchObject({ agent: 'claude', source: 'launch' }) + expect( + resolveCanonicalPaneAgentIdentity({ + foregroundAgent: 'codex', + processProof: { + agent: 'codex', + processIncarnation: 'fixture-process', + authorityId: 'fixture-authority', + capturedAgeMs: 10, + validForMs: undefined as unknown as number + }, + launchAgent: 'claude' + }) + ).toMatchObject({ agent: 'claude', source: 'launch' }) + }) +}) diff --git a/src/shared/agent-status-identity.ts b/src/shared/agent-status-identity.ts index f3d4b65a86b..9cb81625310 100644 --- a/src/shared/agent-status-identity.ts +++ b/src/shared/agent-status-identity.ts @@ -4,6 +4,8 @@ import { type AgentStatusState, type AgentType } from './agent-status-types' +import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' +import type { TuiAgent } from './tui-agent' type ExistingAgentIdentity = { agentType?: AgentType @@ -63,6 +65,11 @@ export function resolveAgentStatusIdentity(args: { inheritedFromActivePane: false } } + const canonical = resolveCanonicalPaneAgentIdentity({ + hookAgent: incomingAgentType as TuiAgent, + hookIsLive: true, + completedHookAgent: args.existing.state === 'done' ? (existingAgentType as TuiAgent) : undefined + }) if (isActiveExistingIdentity(args.existing, args.now, staleAfterMs)) { return { // Why: child agent CLIs inherit ORCA_PANE_KEY from their parent terminal. @@ -74,7 +81,7 @@ export function resolveAgentStatusIdentity(args: { } return { - agentType: incomingAgentType, + agentType: canonical.agent ?? incomingAgentType, inheritedFromActivePane: false } } diff --git a/src/shared/pane-agent-evidence-sources.ts b/src/shared/pane-agent-evidence-sources.ts new file mode 100644 index 00000000000..32b3648494a --- /dev/null +++ b/src/shared/pane-agent-evidence-sources.ts @@ -0,0 +1,19 @@ +/** Evidence classes in canonical strength order, strongest first. */ +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] diff --git a/src/shared/pane-agent-identity-adapter.test.ts b/src/shared/pane-agent-identity-adapter.test.ts new file mode 100644 index 00000000000..d898cb9adcf --- /dev/null +++ b/src/shared/pane-agent-identity-adapter.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import { + buildPaneAgentIdentityEvidenceWire, + isForegroundProcessProofFresh, + resolveCanonicalPaneAgentIdentity, + type ForegroundProcessProof +} from './pane-agent-identity-adapter' + +const freshProof: ForegroundProcessProof = { + agent: 'codex', + processIncarnation: 'opaque-pid-token', + authorityId: 'main:test', + capturedAgeMs: 50, + validForMs: 5_000 +} + +describe('per-pane coverage gate', () => { + it('covers a pane from hook, launch, or sleeping-session evidence alone', () => { + expect( + resolveCanonicalPaneAgentIdentity({ hookAgent: 'claude', hookIsLive: true }).coverage + ).toBe('covered') + expect(resolveCanonicalPaneAgentIdentity({ completedHookAgent: 'claude' }).coverage).toBe( + 'covered' + ) + expect(resolveCanonicalPaneAgentIdentity({ launchAgent: 'codex' }).coverage).toBe('covered') + expect(resolveCanonicalPaneAgentIdentity({ sleepingSessionAgent: 'gemini' }).coverage).toBe( + 'covered' + ) + }) + + it('never covers a pane from a title, a sibling, or a bare foreground name', () => { + expect(resolveCanonicalPaneAgentIdentity({ title: 'claude' }).coverage).toBe('uncovered') + expect( + resolveCanonicalPaneAgentIdentity({ siblingAgent: 'claude', allowSibling: true }).coverage + ).toBe('uncovered') + expect(resolveCanonicalPaneAgentIdentity({ foregroundAgent: 'codex' }).coverage).toBe( + 'uncovered' + ) + }) + + it('is computed from evidence, never from a platform or remote flag', () => { + // The input deliberately has no platform/isRemote field to branch on; this pins that a + // hook-covered pane resolves identically regardless of any caller-side host knowledge. + const identity = resolveCanonicalPaneAgentIdentity({ hookAgent: 'claude', hookIsLive: true }) + expect(identity).toMatchObject({ agent: 'claude', source: 'live-hook', coverage: 'covered' }) + }) +}) + +describe('process rung requires a host-stamped proof', () => { + it('rejects a stale or malformed proof and accepts a fresh one', () => { + expect(isForegroundProcessProofFresh(freshProof)).toBe(true) + expect(isForegroundProcessProofFresh({ ...freshProof, capturedAgeMs: 6_000 })).toBe(false) + expect(isForegroundProcessProofFresh({ ...freshProof, capturedAgeMs: -1 })).toBe(false) + expect(isForegroundProcessProofFresh({ ...freshProof, validForMs: 0 })).toBe(false) + expect(isForegroundProcessProofFresh({ ...freshProof, capturedAgeMs: Number.NaN })).toBe(false) + }) + + it('a bare foreground name cannot outrank launch; a proven process can', () => { + const unproven = resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + foregroundAgent: 'codex' + }) + expect(unproven).toMatchObject({ agent: 'claude', source: 'launch' }) + + const proven = resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + foregroundAgent: 'codex', + processProof: freshProof + }) + expect(proven).toMatchObject({ agent: 'codex', source: 'process', coverage: 'covered' }) + }) + + it('an expired proof and a name-mismatched proof both drop the process rung', () => { + const expired = resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + foregroundAgent: 'codex', + processProof: { ...freshProof, capturedAgeMs: 10_000 } + }) + expect(expired).toMatchObject({ agent: 'claude', source: 'launch' }) + + const mismatched = resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + foregroundAgent: 'gemini', + processProof: freshProof + }) + expect(mismatched).toMatchObject({ agent: 'claude', source: 'launch' }) + }) +}) + +describe('uncovered compatibility lane', () => { + it('preserves the caller-provided legacy result verbatim', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + title: 'Fix the parser - grok', + uncoveredFallback: { agent: 'grok', titleOnly: true } + }) + expect(identity).toMatchObject({ + agent: 'grok', + source: 'title', + coverage: 'uncovered', + titleOnly: true + }) + }) + + it('answers from title evidence marked title-only when no fallback is supplied', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + title: 'STA-4011 Linux Antigravity Commit Messages - grok' + }) + expect(identity).toMatchObject({ + agent: 'grok', + source: 'title', + coverage: 'uncovered', + titleOnly: true + }) + }) + + it('a legacy null stays null rather than re-deriving from the title', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + title: 'anything - grok', + uncoveredFallback: { agent: null } + }) + expect(identity).toMatchObject({ agent: null, source: null, coverage: 'uncovered' }) + }) + + it('does not let a legacy title fallback bypass the ambiguity fence', () => { + expect( + resolveCanonicalPaneAgentIdentity({ + title: 'OC | something - grok', + uncoveredFallback: { agent: 'opencode', titleOnly: true } + }) + ).toMatchObject({ agent: null, source: null, ambiguousAt: 'title' }) + expect( + resolveCanonicalPaneAgentIdentity({ + title: 'compare codex with grok', + uncoveredFallback: { agent: 'codex', titleOnly: true } + }) + ).toMatchObject({ agent: null, source: null, coverage: 'uncovered' }) + }) + + it('does not label a foreground-only compatibility answer as title-only', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + foregroundAgent: 'codex', + uncoveredFallback: { agent: 'codex' } + }) + expect(identity).toMatchObject({ + agent: 'codex', + source: null, + coverage: 'uncovered', + titleOnly: false + }) + }) +}) + +describe('canonical ladder inside the covered lane', () => { + it('keeps title last: a covered launch beats a parsed title', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + title: 'STA-4011 Linux Antigravity Commit Messages - grok' + }) + expect(identity).toMatchObject({ agent: 'claude', source: 'launch', titleOnly: false }) + }) + + it('sibling evidence needs the explicit tab-scope opt-in', () => { + const withoutOptIn = resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + siblingAgent: 'codex' + }) + expect(withoutOptIn.agent).toBe('claude') + const optedIn = resolveCanonicalPaneAgentIdentity({ + hookAgent: 'claude', + hookIsLive: true, + siblingAgent: 'codex', + allowSibling: true + }) + expect(optedIn).toMatchObject({ agent: 'claude', source: 'live-hook' }) + }) + + it('surfaces ambiguity instead of picking by array order', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + hookAgent: 'claude', + hookIsLive: false, + completedHookAgent: 'codex' + }) + expect(identity).toMatchObject({ agent: null, ambiguousAt: 'completed-hook' }) + }) +}) + +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 } + const otherAuthority = { authorityId: 'renderer:b', incarnation: 9 } + + it('bug shape: hook and pane share the current run, so the completed hook wins over the title', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + completedHookAgent: 'claude', + completedHookRun: run1, + currentRun: run1, + title: 'STA-4011 Linux Antigravity Commit Messages - grok' + }) + expect(identity).toMatchObject({ agent: 'claude', source: 'completed-hook' }) + }) + + it('reclaim shape: a superseded hook is ineligible and the current title evidence answers', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + completedHookAgent: 'claude', + completedHookRun: run1, + currentRun: run2, + title: 'STA-4011 Linux Antigravity Commit Messages - grok' + }) + expect(identity).toMatchObject({ + agent: 'grok', + source: 'title', + coverage: 'uncovered', + titleOnly: true + }) + expect(identity.supersededSources).toEqual(['completed-hook']) + }) + + it('cross-authority runs are incomparable, so the hook stays eligible', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + completedHookAgent: 'claude', + completedHookRun: otherAuthority, + currentRun: run2, + title: 'STA-4011 Linux Antigravity Commit Messages - grok' + }) + expect(identity).toMatchObject({ agent: 'claude', source: 'completed-hook' }) + }) + + it('an absent run key keeps evidence eligible (old peer), never guessed stale', () => { + const identity = resolveCanonicalPaneAgentIdentity({ + completedHookAgent: 'claude', + currentRun: run2, + title: 'STA-4011 Linux Antigravity Commit Messages - grok' + }) + expect(identity).toMatchObject({ agent: 'claude', source: 'completed-hook' }) + }) +}) + +describe('action floor', () => { + it("minimumSource: 'launch' refuses title and completed-hook answers outright", () => { + const identity = resolveCanonicalPaneAgentIdentity({ + completedHookAgent: 'claude', + title: 'claude', + minimumSource: 'launch' + }) + expect(identity).toMatchObject({ agent: null, source: null, coverage: 'covered' }) + }) +}) + +describe('wire evidence projection', () => { + it('publishes nothing for an absent identity — absence stays absence', () => { + expect( + buildPaneAgentIdentityEvidenceWire(resolveCanonicalPaneAgentIdentity({})) + ).toBeUndefined() + }) + + it('marks the uncovered title-only route explicitly and carries the run key when known', () => { + const wire = buildPaneAgentIdentityEvidenceWire( + resolveCanonicalPaneAgentIdentity({ title: 'claude - claude' }), + { authorityId: 'main:a', incarnation: 3 }, + { capturedAgeMs: 10, validForMs: 1_000 } + ) + expect(wire).toMatchObject({ + coverage: 'uncovered', + titleOnlyActionFallback: true, + authorityId: 'main:a', + incarnation: 3, + freshness: { capturedAgeMs: 10, validForMs: 1_000 } + }) + }) + + it('a covered identity never carries the title-only action marker', () => { + const wire = buildPaneAgentIdentityEvidenceWire( + resolveCanonicalPaneAgentIdentity({ launchAgent: 'claude' }) + ) + expect(wire).toMatchObject({ source: 'launch', coverage: 'covered' }) + expect(wire?.titleOnlyActionFallback).toBeUndefined() + }) +}) diff --git a/src/shared/pane-agent-identity-adapter.ts b/src/shared/pane-agent-identity-adapter.ts new file mode 100644 index 00000000000..c08e1b77dea --- /dev/null +++ b/src/shared/pane-agent-identity-adapter.ts @@ -0,0 +1,361 @@ +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { PANE_AGENT_EVIDENCE_SOURCES } from './pane-agent-evidence-sources' +import type { + PaneAgentEvidence, + PaneAgentIdentity, + PaneAgentIdentityInput, + PaneAgentRunKey +} from './pane-agent-identity-resolver' +import type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources' +import type { TuiAgent } from './tui-agent' + +/** + * Canonical pane identity ranking. All adapters, including the compatibility resolver, delegate to + * this implementation so source precedence, ambiguity, and run eligibility cannot drift. + */ + +/** + * Whether the execution authority proved at least one identity-bearing source for this pane. + * Computed from evidence presence, never from `platform`, `isRemote`, or OS: a remote pane with a + * host-stamped hook is covered; a local pane with only a title is uncovered. + */ +export type PaneAgentCoverage = 'covered' | 'uncovered' + +/** + * Host-stamped proof that a recognized agent process is the pane's foreground process. + * + * A process NAME is not a PID-reuse-safe identity, so a bare foreground read never enters the + * covered process rung. `processIncarnation` is an opaque token the execution host derives from + * the selected PID plus start/creation time (or an equivalent platform-native identity); the raw + * tuple never crosses the renderer/remote wire. The host emits no proof when the start identity + * is unavailable or ambiguous, so that pane reads `uncovered` rather than guessed. + */ +export type ForegroundProcessProof = { + agent: TuiAgent + /** Opaque host-derived PID+start-time token. Compared for equality only, never decoded. */ + processIncarnation: string + ptyIncarnationId?: string + /** The execution authority that stamped the proof (see agent-status-observation.ts). */ + authorityId: string + /** Age on the AUTHORITY's clock at capture. Replicas decay from this plus `validForMs`, + * never by subtracting a host wall clock from a local `Date.now()`. */ + capturedAgeMs: number + validForMs: number +} + +/** + * Positive evidence that a pane/process was REPLACED, required before any consumer may advance a + * pane incarnation. A retired-pane `restart` disposition, an accepted send, an ordinary provider + * turn boundary, a title change, a transport loss, or a renderer-only foreground change is never + * one of these. Defined here so the rebind-gate wave has a contract to be correct against; no + * sequencer call site consumes it yet. + */ +export type PaneReplacementProof = + | { kind: 'accepted-launch'; launchToken: string; ptyIncarnationId: string } + | { kind: 'process-replacement'; processIncarnation: string; authorityId: string } + | { kind: 'provider-session-attach'; providerSessionId: string } + +/** + * The optional wire object a host will publish alongside `agentIdentity` after capability + * negotiation (host-publisher wave, not now). All fields bounded and JSON-safe; old peers ignore + * it. Never inferred from the bare `agentIdentity` string. + */ +export type PaneAgentIdentityEvidenceWire = { + source: PaneAgentEvidenceSource + coverage: PaneAgentCoverage + authorityId?: string + incarnation?: number + freshness?: { capturedAgeMs: number; validForMs: number } + /** Marks the scoped host-published title-only best-effort route (hand-started WSL panes). + * Counted separately, `unverifiable` for liveness, and never relabeled as covered proof. */ + titleOnlyActionFallback?: true +} + +export type CanonicalPaneAgentIdentityInput = { + hookAgent?: TuiAgent | null + hookIsLive?: boolean + hookRun?: PaneAgentRunKey + /** A distinct completed-hook signal for callers that hold live and completed rows separately + * (the tab ladder does); `hookAgent` + `hookIsLive: false` remains the single-slot spelling. */ + completedHookAgent?: TuiAgent | null + completedHookRun?: PaneAgentRunKey + launchAgent?: TuiAgent | null + launchRun?: PaneAgentRunKey + /** + * Foreground process NAME as currently read. Without a fresh `processProof` this is a weak + * hint: it neither enters the covered process rung nor makes the pane covered. + */ + foregroundAgent?: TuiAgent | null + processProof?: ForegroundProcessProof | null + sleepingSessionAgent?: TuiAgent | null + sleepingRun?: PaneAgentRunKey + /** Tab-level display fallback only; ignored unless `allowSibling` opts in. */ + siblingAgent?: TuiAgent | null + /** Additional tab-level sibling observations retained for ambiguity checking. */ + siblingAgents?: readonly TuiAgent[] + allowSibling?: boolean + title?: string | null + currentRun?: PaneAgentRunKey + minimumSource?: PaneAgentEvidenceSource + /** + * The caller's CURRENT ladder result, preserved verbatim while the pane is uncovered. The + * uncovered lane is a temporary compatibility lane, not a new host-specific ranking; absent a + * fallback, an uncovered pane answers from title evidence alone, marked title-only. + */ + uncoveredFallback?: { agent: TuiAgent | null; titleOnly?: boolean } +} + +export type CanonicalPaneAgentIdentity = { + agent: TuiAgent | null + source: PaneAgentEvidenceSource | null + coverage: PaneAgentCoverage + /** True when the answer was derived from a parsed title (the uncovered/title-only marking). */ + titleOnly: boolean + ambiguousAt?: PaneAgentEvidenceSource + supersededSources: readonly PaneAgentEvidenceSource[] +} + +/** Authority order, strongest first. This is the only place precedence is expressed. */ +const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES + +/** Exported for the source/rank drift ratchet; the rank is the canonical source list itself. */ +export const PANE_AGENT_SOURCE_RANK = SOURCE_RANK + +/** Reject an unrecognised source instead of silently dropping it from the ranking loop. */ +function sourceRankIndex(source: PaneAgentEvidenceSource): number { + const index = SOURCE_RANK.indexOf(source) + if (index === -1) { + throw new Error(`Unknown pane-agent evidence source: ${String(source)}`) + } + return index +} + +/** Run keys only supersede evidence from the same authority; unknown authorities stay eligible. */ +function isPaneAgentRunEligible( + run: PaneAgentRunKey | undefined, + currentRun: PaneAgentRunKey | undefined +): boolean { + return ( + run === undefined || + currentRun === undefined || + run.authorityId !== currentRun.authorityId || + run.incarnation === currentRun.incarnation + ) +} + +/** Shared evidence ranking primitive used by every pane-identity adapter. */ +export function resolveCanonicalPaneAgentEvidence( + input: PaneAgentIdentityInput +): PaneAgentIdentity { + const superseded: PaneAgentEvidenceSource[] = [] + const floor = input.minimumSource ? sourceRankIndex(input.minimumSource) : Number.MAX_SAFE_INTEGER + const eligible = input.evidence.filter((item) => { + if (item.source === 'sibling' && input.allowSibling !== true) { + return false + } + if (sourceRankIndex(item.source) > floor) { + return false + } + if (isPaneAgentRunEligible(item.run, input.currentRun)) { + return true + } + superseded.push(item.source) + return false + }) + + for (const source of SOURCE_RANK) { + const matches = eligible.filter((item) => item.source === source) + if (matches.length === 0) { + continue + } + const agents = new Set(matches.map((item) => item.agent)) + if (agents.size > 1) { + return { agent: null, source: null, ambiguousAt: source, supersededSources: superseded } + } + return { agent: matches[0].agent, source, supersededSources: superseded } + } + return { agent: null, source: null, supersededSources: superseded } +} + +/** Freshness is judged on the authority's own clock: age at capture against its TTL. */ +export function isForegroundProcessProofFresh(proof: ForegroundProcessProof): boolean { + return ( + Number.isFinite(proof.capturedAgeMs) && + Number.isFinite(proof.validForMs) && + proof.capturedAgeMs >= 0 && + proof.validForMs > 0 && + proof.capturedAgeMs <= proof.validForMs + ) +} + +/** A proof only carries identity for the agent it names; a name mismatch is no proof at all. */ +function processEvidenceFromProof( + input: CanonicalPaneAgentIdentityInput +): PaneAgentEvidence | null { + const proof = input.processProof + if (!proof || !isForegroundProcessProofFresh(proof)) { + return null + } + if (input.foregroundAgent && input.foregroundAgent !== proof.agent) { + return null + } + return { source: 'process', agent: proof.agent } +} + +export function resolveCanonicalPaneAgentIdentity( + input: CanonicalPaneAgentIdentityInput +): CanonicalPaneAgentIdentity { + const processEvidence = processEvidenceFromProof(input) + // Coverage comes from authority-bearing sources that are still eligible for this run. A stale + // hook/launch row can remain in the input after a pane is replaced; it must not make a title-only + // answer look covered to a future action consumer. + const covered = Boolean( + (input.hookAgent && isPaneAgentRunEligible(input.hookRun, input.currentRun)) || + (input.completedHookAgent && + isPaneAgentRunEligible(input.completedHookRun, input.currentRun)) || + processEvidence || + (input.launchAgent && isPaneAgentRunEligible(input.launchRun, input.currentRun)) || + (input.sleepingSessionAgent && isPaneAgentRunEligible(input.sleepingRun, input.currentRun)) + ) + // Keep stale evidence in the resolver so diagnostics still report which source was superseded, + // even when it no longer qualifies the pane as covered. + const hasAuthorityEvidence = Boolean( + input.hookAgent || + input.completedHookAgent || + processEvidence || + input.launchAgent || + input.sleepingSessionAgent + ) + const titleEvidence = input.title ? collectAgentTitleEvidence(input.title) : null + const titleAgent = titleEvidence?.agent ?? null + + 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) || + titleEvidence?.reason === 'conflicting-anchored-names' || + titleEvidence?.reason === 'conflicting-vendor-markers') + if (rejectTitleFallback) { + return { + agent: null, + source: null, + coverage: 'uncovered', + titleOnly: false, + ...(titleEvidence?.reason === 'free-text-only' ? {} : { ambiguousAt: 'title' as const }), + supersededSources: [] + } + } + const titleOnly = + input.uncoveredFallback.titleOnly ?? (agent !== null && agent === titleAgent) + return { + agent, + source: agent === null ? null : titleOnly ? 'title' : null, + coverage: 'uncovered', + titleOnly, + supersededSources: [] + } + } + const siblingEvidence = [ + ...(input.siblingAgent ? [{ source: 'sibling' as const, agent: input.siblingAgent }] : []), + ...(input.siblingAgents?.map((agent) => ({ source: 'sibling' as const, agent })) ?? []), + ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : []) + ] + const siblingResolved = resolveCanonicalPaneAgentEvidence({ + evidence: siblingEvidence, + allowSibling: input.allowSibling, + minimumSource: input.minimumSource + }) + return { + agent: siblingResolved.agent, + source: siblingResolved.source, + coverage: 'uncovered', + titleOnly: siblingResolved.source === 'title', + ...(siblingResolved.ambiguousAt ? { ambiguousAt: siblingResolved.ambiguousAt } : {}), + supersededSources: siblingResolved.supersededSources + } + } + + const resolved = resolveCanonicalPaneAgentEvidence({ + evidence: [ + ...(input.hookAgent + ? [ + { + source: input.hookIsLive ? ('live-hook' as const) : ('completed-hook' as const), + agent: input.hookAgent, + ...(input.hookRun ? { run: input.hookRun } : {}) + } + ] + : []), + ...(input.completedHookAgent + ? [ + { + source: 'completed-hook' as const, + agent: input.completedHookAgent, + ...(input.completedHookRun ? { run: input.completedHookRun } : {}) + } + ] + : []), + ...(processEvidence ? [processEvidence] : []), + ...(input.launchAgent + ? [ + { + source: 'launch' as const, + agent: input.launchAgent, + ...(input.launchRun ? { run: input.launchRun } : {}) + } + ] + : []), + ...(input.sleepingSessionAgent + ? [ + { + source: 'sleeping-session' as const, + agent: input.sleepingSessionAgent, + ...(input.sleepingRun ? { run: input.sleepingRun } : {}) + } + ] + : []), + ...(input.siblingAgent ? [{ source: 'sibling' as const, agent: input.siblingAgent }] : []), + ...(input.siblingAgents?.map((agent) => ({ source: 'sibling' as const, agent })) ?? []), + ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : []) + ], + currentRun: input.currentRun, + minimumSource: input.minimumSource, + allowSibling: input.allowSibling + }) + return { + agent: resolved.agent, + source: resolved.source, + coverage: covered ? 'covered' : 'uncovered', + titleOnly: resolved.source === 'title', + ...(resolved.ambiguousAt ? { ambiguousAt: resolved.ambiguousAt } : {}), + supersededSources: resolved.supersededSources + } +} + +/** Projects the host-local sidecar onto the optional wire shape. Returns undefined when there is + * nothing to publish — absence stays absence, and a bare `agentIdentity` with no sidecar is + * never treated as covered proof by any consumer. */ +export function buildPaneAgentIdentityEvidenceWire( + identity: CanonicalPaneAgentIdentity, + run?: PaneAgentRunKey, + freshness?: { capturedAgeMs: number; validForMs: number } +): PaneAgentIdentityEvidenceWire | undefined { + if (identity.agent === null || identity.source === null) { + return undefined + } + return { + source: identity.source, + coverage: identity.coverage, + ...(run ? { authorityId: run.authorityId, incarnation: run.incarnation } : {}), + ...(freshness ? { freshness } : {}), + ...(identity.coverage === 'uncovered' && identity.titleOnly + ? { titleOnlyActionFallback: true as const } + : {}) + } +} diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 6871a38eb6d..ee868bfcc16 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -18,7 +18,13 @@ const HELPERS = [ 'resolveExplicitTerminalTitleAgentType', 'resolveCommittedTitleAgentType', 'resolvePaneAgentOwner', - 'resolveCompatibleAgentTypeForOwner' + 'resolveCompatibleAgentTypeForOwner', + 'classifyTitleActivity', + 'detectAgentStatusFromTitle', + 'resolveAgentTypeFromTerminalTitle', + 'resolvePaneAgentIdentity', + 'resolveCanonicalPaneAgentIdentity', + 'resolvePublishedPaneAgentIdentity' ] as const const TEST_SUPPORT_PATHS = new Set([ @@ -242,6 +248,136 @@ const INVENTORY: readonly InventoryGroup[] = [ ['src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts', 2], ['src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts', 2] ] + }, + { + helper: 'classifyTitleActivity', + classification: 'identity-consumer', + paths: [ + ['src/renderer/src/components/sidebar/smart-attention.ts', 3], + ['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] + ] + }, + { + helper: 'classifyTitleActivity', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/terminal-pane/cache-timer-seeding.ts', 2], + ['src/renderer/src/lib/agent-ready-wait.ts', 2], + ['src/renderer/src/store/terminals/terminal-ephemeral-state.ts', 2] + ] + }, + { + helper: 'classifyTitleActivity', + classification: 'activity-only', + paths: [ + ['src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts', 3], + ['src/renderer/src/store/terminals/terminal-tab-presentation.ts', 4] + ] + }, + { + helper: 'classifyTitleActivity', + classification: 'evidence-producer', + paths: [ + ['src/renderer/src/lib/agent-send-title-status.ts', 2], + ['src/renderer/src/lib/agent-status-terminal-title.ts', 2] + ] + }, + { + helper: 'classifyTitleActivity', + classification: 'parser-implementation', + paths: [ + ['src/renderer/src/lib/agent-status.ts', 5], + 'src/renderer/src/lib/pane-agent-evidence.ts' + ] + }, + { + helper: 'detectAgentStatusFromTitle', + classification: 'evidence-producer', + paths: [ + ['src/main/runtime/orca-runtime-apply-tracked-pty-title.ts', 2], + ['src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts', 2], + ['src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts', 2], + ['src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts', 2], + ['src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts', 2], + ['src/main/runtime/runtime-terminal-agent-status-query.ts', 3], + ['src/main/runtime/runtime-worktree-status-projection.ts', 4], + ['src/main/runtime/terminal-wait-detection.ts', 2], + ['src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts', 2], + ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 4], + ['src/renderer/src/components/terminal-pane/pty-output-title-observer.ts', 2], + ['src/shared/terminal-output-side-effects.ts', 3] + ] + }, + { + helper: 'detectAgentStatusFromTitle', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2], + [ + 'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', + 3 + ], + ['src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts', 3] + ] + }, + { + helper: 'detectAgentStatusFromTitle', + classification: 'parser-implementation', + paths: [ + ['src/renderer/src/components/terminal-pane/title-agent-identity.ts', 2], + 'src/renderer/src/lib/agent-status.ts', + ['src/renderer/src/lib/pane-agent-evidence.ts', 3], + ['src/shared/agent-decorative-title-signature.ts', 2], + 'src/shared/agent-detection.ts', + ['src/shared/agent-title-owner.ts', 2], + ['src/shared/agent-title-status.ts', 6] + ] + }, + { + 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] + ] + }, + { + helper: 'resolvePaneAgentIdentity', + classification: 'parser-implementation', + paths: ['src/shared/pane-agent-identity-resolver.ts'] + }, + { + helper: 'resolvePaneAgentIdentity', + classification: 'identity-consumer', + paths: [['src/shared/published-pane-agent-identity.ts', 2]] + }, + { + helper: 'resolveCanonicalPaneAgentIdentity', + classification: 'parser-implementation', + paths: ['src/shared/pane-agent-identity-adapter.ts'] + }, + { + helper: 'resolveCanonicalPaneAgentIdentity', + classification: 'identity-consumer', + paths: [['src/shared/agent-status-identity.ts', 2]] + }, + { + helper: 'resolveCanonicalPaneAgentIdentity', + classification: 'identity-consumer', + paths: [['src/shared/terminal-title-agent-type.ts', 2]] + }, + { + helper: 'resolvePublishedPaneAgentIdentity', + classification: 'parser-implementation', + paths: [ + 'src/shared/published-pane-agent-identity.ts', + ['src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts', 2] + ] } ] diff --git a/src/shared/pane-agent-identity-resolver.test.ts b/src/shared/pane-agent-identity-resolver.test.ts index e28fb0cbd22..2f14a54976a 100644 --- a/src/shared/pane-agent-identity-resolver.test.ts +++ b/src/shared/pane-agent-identity-resolver.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { PANE_AGENT_SOURCE_RANK } from './pane-agent-identity-adapter' import { PANE_AGENT_EVIDENCE_SOURCES, type PaneAgentEvidence, @@ -11,6 +12,14 @@ const resolve = (evidence: PaneAgentEvidence[], extra = {}) => const H = 'authority-a' describe('resolvePaneAgentIdentity', () => { + it('keeps every evidence source ranked exactly once', () => { + expect(PANE_AGENT_SOURCE_RANK).toBe(PANE_AGENT_EVIDENCE_SOURCES) + expect(new Set(PANE_AGENT_SOURCE_RANK).size).toBe(PANE_AGENT_SOURCE_RANK.length) + for (const source of PANE_AGENT_EVIDENCE_SOURCES) { + expect(PANE_AGENT_SOURCE_RANK.indexOf(source)).toBeGreaterThanOrEqual(0) + } + }) + 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', @@ -153,6 +162,17 @@ describe('resolvePaneAgentIdentity', () => { }) }) + it('fails loudly when an evidence source is missing from the rank', () => { + expect(() => + resolve([ + { + source: 'future-source' as PaneAgentEvidence['source'], + agent: 'codex' + } + ]) + ).toThrow('Unknown pane-agent evidence source') + }) + describe('input order does not decide the answer', () => { it('resolves the same regardless of how evidence is listed', () => { const evidence: PaneAgentEvidence[] = [ diff --git a/src/shared/pane-agent-identity-resolver.ts b/src/shared/pane-agent-identity-resolver.ts index 98342963415..4b03c5de16d 100644 --- a/src/shared/pane-agent-identity-resolver.ts +++ b/src/shared/pane-agent-identity-resolver.ts @@ -1,5 +1,10 @@ +import { resolveCanonicalPaneAgentEvidence } from './pane-agent-identity-adapter' +import type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources' import type { TuiAgent } from './tui-agent' +export { PANE_AGENT_EVIDENCE_SOURCES } from './pane-agent-evidence-sources' +export type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources' + /** * One place that answers "which agent is in this pane". * @@ -23,27 +28,6 @@ import type { TuiAgent } from './tui-agent' * 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] - -/** Authority order, strongest first. Position here is the ONLY place precedence is expressed. */ -const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES - /** * Which agent run a piece of evidence belongs to. * @@ -112,52 +96,5 @@ export type PaneAgentIdentity = { export function resolvePaneAgentIdentity( input: PaneAgentIdentityInput ): PaneAgentIdentity { - const superseded: PaneAgentEvidenceSource[] = [] - const floor = input.minimumSource - ? SOURCE_RANK.indexOf(input.minimumSource) - : Number.MAX_SAFE_INTEGER - - const eligible = input.evidence.filter((item) => { - if (item.source === 'sibling' && input.allowSibling !== true) { - return false - } - // Why the floor: an action consumer must not be able to act on a title, at any rank. Dropping - // the evidence entirely rather than ranking it lower makes misuse impossible rather than - // unlikely — a caller cannot accidentally consult it by reordering. - if (SOURCE_RANK.indexOf(item.source) > floor) { - return false - } - if (input.currentRun === undefined || item.run === undefined) { - // Why eligible: absence means "this peer does not publish run keys", not "this is stale". - // Treating unknown as superseded would blank every row from an older host. - return true - } - if (item.run.authorityId !== input.currentRun.authorityId) { - // Why eligible and NOT superseded: runs from different authorities are incomparable, not - // older. A restarted main counts from its own floor, so `incarnation` alone would falsely - // equate unrelated runs. Incomparable evidence is treated as unknown, like an absent key. - return true - } - if (item.run.incarnation === input.currentRun.incarnation) { - return true - } - superseded.push(item.source) - return false - }) - - for (const source of SOURCE_RANK) { - const matches = eligible.filter((item) => item.source === source) - if (matches.length === 0) { - continue - } - const agents = new Set(matches.map((item) => item.agent)) - if (agents.size > 1) { - // Why null and not the first: two observations of the same class naming different agents is - // a genuine conflict, and picking one would make the answer depend on array order — the very - // property this resolver exists to remove. Fall through to nothing rather than guess. - return { agent: null, source: null, ambiguousAt: source, supersededSources: superseded } - } - return { agent: matches[0].agent, source, supersededSources: superseded } - } - return { agent: null, source: null, supersededSources: superseded } + return resolveCanonicalPaneAgentEvidence(input) } diff --git a/src/shared/pane-agent-identity-surface-inventory.test.ts b/src/shared/pane-agent-identity-surface-inventory.test.ts new file mode 100644 index 00000000000..77d9edb3b84 --- /dev/null +++ b/src/shared/pane-agent-identity-surface-inventory.test.ts @@ -0,0 +1,284 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { glob } from 'tinyglobby' +import { isTestFile, stripComments } from './source-scan/source-tree-scan' + +/** + * Surface half of the identity inventory ratchet: every consumer decision point from the closed + * 65-row inventory (rows 32–65 — the direct title/native-chat selectors, tab projections, mobile + * sync graph, lifecycle selectors, status/OSC ingress, worktree status, attention, and + * title-reset paths) is pinned to a marker symbol in its file. The helper-name census + * (`pane-agent-identity-inventory.test.ts`) is necessary but not sufficient — these files reach + * identity through direct reads a name census cannot see. Moving or renaming a marker means the + * inventory row must be re-classified, deliberately, before review. + */ + +type SurfaceRow = { + /** Row number in the closed consumer inventory. */ + row: number + path: string + marker: string +} + +const SURFACE_ROWS: readonly SurfaceRow[] = [ + { + row: 32, + path: 'src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', + marker: 'resolveNativeChatLeafTitleAgent' + }, + { + row: 32, + path: 'src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts', + marker: 'resolveNativeChatLeafTitleAgent' + }, + { + row: 33, + path: 'src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', + marker: 'installPaneAgentIdentity' + }, + { row: 34, path: 'src/main/runtime/orchestration/groups.ts', marker: 'terminalIsAgent' }, + { + row: 35, + path: 'src/renderer/src/lib/active-agent-note-target.ts', + marker: 'getActiveTerminalNoteTarget' + }, + { + row: 36, + path: 'src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts', + marker: 'resolveProtectedMultilinePasteOptionsForPane' + }, + { + row: 37, + path: 'src/renderer/src/components/terminal-pane/command-code-output-ownership.ts', + marker: 'canCommandCodeOutputOwnPane' + }, + { row: 38, path: 'src/renderer/src/lib/agent-ready-wait.ts', marker: 'waitForAgentReady' }, + { + row: 39, + path: 'src/renderer/src/lib/agent-paste-draft.ts', + marker: 'getSettingsForAgentTabRuntimeOwner' + }, + { + row: 40, + path: 'src/renderer/src/lib/agent-followup-delivery.ts', + marker: 'sendFollowupPromptWhenAgentReady' + }, + { + row: 41, + path: 'src/renderer/src/lib/codex-session-restart.ts', + marker: 'markLiveCodexSessionsForRestart' + }, + { + row: 41, + path: 'src/renderer/src/lib/codex-pane-restart-eligibility.ts', + marker: 'isCodexForegroundProcess' + }, + { + row: 42, + path: 'src/renderer/src/components/native-chat/native-chat-availability.ts', + marker: 'canToggleNativeChat' + }, + { + row: 43, + path: 'src/renderer/src/components/native-chat/native-chat-pane-resolution.ts', + marker: 'resolveNativeChatSession' + }, + { + row: 44, + path: 'src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts', + marker: 'canContinueAgentSessionInNewSession' + }, + { + row: 45, + path: 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', + marker: 'prepareAgentSessionForkFromPane' + }, + { + row: 46, + path: 'src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts', + marker: 'isPlainEscapeKeyEvent' + }, + { + row: 46, + path: 'src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts', + marker: 'inferQuestionAnsweredFromCurrentStatus' + }, + { + row: 47, + path: 'src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts', + marker: 'resolvePaneKeyboardProtocolAgent' + }, + { + row: 47, + path: 'src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts', + marker: 'resolvePaneKeyboardProtocolAgent' + }, + { + row: 48, + path: 'src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts', + marker: 'selectTabAgentTypesByTabId' + }, + { + row: 49, + path: 'src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts', + marker: 'createTerminalTabAgentTypeSelector' + }, + { + row: 50, + path: 'src/renderer/src/lib/tab-agent-status-index.ts', + marker: 'selectLiveTabAgentPanes' + }, + { + row: 51, + path: 'src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts', + marker: 'resolveTerminalTabActivityStatus' + }, + { + row: 52, + path: 'src/renderer/src/lib/workspace-tab-agent-metadata.ts', + marker: 'maxAgentActivityAt' + }, + { + row: 52, + path: 'src/renderer/src/lib/workspace-tab-palette-entry-builder.ts', + marker: 'buildSearchableWorkspaceTabEntries' + }, + { + row: 53, + path: 'src/renderer/src/lib/running-agent-targets.ts', + marker: 'deriveRunningAgentSendTargets' + }, + { + row: 54, + path: 'src/renderer/src/runtime/sync-runtime-graph.ts', + marker: 'buildMobileSessionTabSnapshots' + }, + { + row: 55, + path: 'src/renderer/src/lib/agent-hibernation-pane-eligibility.ts', + marker: 'toRuntimePtyId' + }, + { + row: 56, + path: 'src/renderer/src/lib/resume-sleeping-agent-session.ts', + marker: 'resumeSleepingAgentSessionsForWorktree' + }, + { + row: 57, + path: 'src/renderer/src/lib/automation-session-reuse.ts', + marker: 'findReusableAutomationSession' + }, + { + row: 58, + path: 'src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts', + marker: 'bindBuildColdRestoreAgentResumeStartup' + }, + { + row: 59, + path: 'src/main/agent-hooks/server/server-authority-evidence.ts', + marker: 'recordCurrentAuthorityObservation' + }, + { + row: 59, + path: 'src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts', + marker: 'resolvePaneAgentIdentityField' + }, + { + row: 59, + path: 'src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts', + marker: 'createAgentStatusEventApplicator' + }, + { + row: 59, + path: 'src/renderer/src/store/slices/agent-status-authority-actions.ts', + marker: 'transferAgentPaneAuthority' + }, + { + row: 59, + path: 'src/renderer/src/store/slices/pane-foreground-agent.ts', + marker: 'createPaneForegroundAgentSlice' + }, + { + row: 59, + path: 'src/renderer/src/hooks/ipc-events/agent-status-routing.ts', + marker: 'isAgentStatusForRecentlyClosedTab' + }, + { + row: 60, + path: 'src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts', + marker: 'installTitleSpawnBell' + }, + { row: 61, path: 'src/renderer/src/lib/worktree-status.ts', marker: 'getWorktreeStatus' }, + { + row: 62, + path: 'src/main/runtime/runtime-worktree-status-projection.ts', + marker: 'getLeafWorktreeStatus' + }, + { + row: 63, + path: 'src/renderer/src/components/sidebar/smart-attention.ts', + marker: 'buildAttentionByWorktree' + }, + { + row: 64, + path: 'src/renderer/src/components/status-bar/workspace-space-presentation.ts', + marker: 'countWorkspaceSpaceActiveAgents' + }, + { row: 65, path: 'src/renderer/src/store/slices/terminal-helpers.ts', marker: 'getResetTitle' }, + { + row: 6, + path: 'src/renderer/src/runtime/web-session-tabs-sync.ts', + marker: 'applyWebSessionTabs' + } +] + +describe('pane agent identity surface inventory (rows 6, 32–65)', () => { + it('every pinned surface still carries its marker symbol', () => { + for (const row of SURFACE_ROWS) { + const source = stripComments(readFileSync(join(process.cwd(), row.path), 'utf8')) + expect({ row: row.row, path: row.path, hasMarker: source.includes(row.marker) }).toEqual({ + row: row.row, + path: row.path, + hasMarker: true + }) + } + }) +}) + +/** + * Identity-observation rebind audit. Advancing a pane incarnation without a positive replacement + * proof is how a legitimate reclaim and a stale-hook bug get conflated (see + * `PaneReplacementProof` in pane-agent-identity-adapter.ts). Every existing sequencer `rebind` + * call is pinned here by file and count: today they are the retired-pane `restart` disposition + * (three ingress paths) and the renderer pane-key transfer. Adding a rebind call, or changing + * these, requires updating this audit — and per the migration plan, a `replacementProof`. + */ +const IDENTITY_SEQUENCER_REBIND_RE = /\b(?:observations|rendererAgentStatusObservations)\.rebind\(/g + +const EXPECTED_REBIND_SITES: readonly (readonly [path: string, occurrences: number])[] = [ + ['src/main/agent-hooks/server/server-ingest-normalization.ts', 1], + ['src/main/agent-hooks/server/server-ingest-remote.ts', 1], + ['src/main/agent-hooks/server/server-lifecycle.ts', 1], + ['src/renderer/src/store/slices/agent-status-authority-actions.ts', 1] +] + +describe('identity observation rebind audit', () => { + it('pins every identity-sequencer rebind call site by file and count', async () => { + const files = await glob(['src/**/*.{ts,tsx}', 'mobile/src/**/*.{ts,tsx}'], { + ignore: ['**/*.test.*', '**/*.spec.*'] + }) + const actual: [string, number][] = [] + for (const path of files.sort()) { + if (isTestFile(path)) { + continue + } + const source = stripComments(readFileSync(join(process.cwd(), path), 'utf8')) + const occurrences = source.match(IDENTITY_SEQUENCER_REBIND_RE)?.length ?? 0 + if (occurrences > 0) { + actual.push([path, occurrences]) + } + } + expect(actual).toEqual(EXPECTED_REBIND_SITES.map((site) => [...site])) + }, 30_000) +}) diff --git a/src/shared/pane-agent-identity-title-corpus.test.ts b/src/shared/pane-agent-identity-title-corpus.test.ts new file mode 100644 index 00000000000..f24cd431065 --- /dev/null +++ b/src/shared/pane-agent-identity-title-corpus.test.ts @@ -0,0 +1,156 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { createHash, randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' +import type { TuiAgent } from './tui-agent' + +/** + * Title regression gates for the identity-ladder migration. + * + * Two layers: a controlled fixture table that always runs (CI-safe), and a local characterization + * gate over the machine's real recorded corpus. The corpus gate is not a CI prerequisite tied to + * one developer's home — when no history exists it reports `corpus unavailable — skipped` + * explicitly, never a silently green zero-title run. Raw titles never reach logs or failure + * output; changed titles are reported as salted hashes plus old/new agent summaries only. + */ + +const RECORDED_HISTORY_DIR = 'terminal-history' +const QUARANTINE_DIR = '.recovery-quarantine' + +function orcaAppSupportCandidates(): string[] { + if (process.platform === 'darwin') { + return [join(homedir(), 'Library', 'Application Support', 'Orca')] + } + if (process.platform === 'win32') { + return [join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Orca')] + } + return [ + join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'Orca'), + join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'Orca') + ] +} + +/** Deliberately shallow: `terminal-history//checkpoint.json` only, with the hidden + * quarantine subtree excluded BY NAME so a future recursive rewrite cannot silently turn + * quarantined recovery data into product regressions. */ +function loadRecordedTitleCorpus(): { checkpointCount: number; titles: string[] } | null { + const root = orcaAppSupportCandidates() + .map((candidate) => join(candidate, RECORDED_HISTORY_DIR)) + .find((candidate) => existsSync(candidate)) + if (!root) { + return null + } + let checkpointCount = 0 + const titles = new Set() + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === QUARANTINE_DIR || entry.name.startsWith('.')) { + continue + } + const checkpointPath = join(root, entry.name, 'checkpoint.json') + if (!existsSync(checkpointPath)) { + continue + } + const parsed: unknown = JSON.parse(readFileSync(checkpointPath, 'utf8')) + checkpointCount += 1 + const lastTitle = (parsed as { lastTitle?: unknown }).lastTitle + if (typeof lastTitle === 'string' && lastTitle.length > 0) { + titles.add(lastTitle) + } + } + return { checkpointCount, titles: [...titles] } +} + +/** What the canonical adapter answers when a title is all a pane has (the uncovered lane). */ +function canonicalTitleOnlyAgent(title: string): TuiAgent | null { + return resolveCanonicalPaneAgentIdentity({ title }).agent +} + +describe('controlled title fixtures (always run)', () => { + const FIXTURES: readonly { name: string; title: string; expected: TuiAgent | null }[] = [ + { + name: 'mandatory adversarial owner suffix beats the agent names in task text', + title: 'STA-4011 Linux Antigravity Commit Messages - grok', + expected: 'grok' + }, + { + name: 'task text mentioning other agents is not identity', + title: 'Compare Antigravity with Gemini 3.7 Flash', + expected: null + }, + { + name: 'owner suffix still answers over mentioned agents', + title: 'Compare Antigravity with Gemini 3.7 Flash… - grok', + expected: 'grok' + }, + { name: 'Claude status sigil is a vendor marker', title: '✳', expected: 'claude' }, + { name: 'Claude management screen is not identity', title: 'claude agents', expected: null }, + { name: 'a shell title names no agent', title: 'zsh', expected: null }, + { name: 'a default worktree-ish title names no agent', title: 'my-claude-fix', expected: null }, + { + name: 'conflicting vendor markers resolve to nothing', + title: '✳ | ✦ two sigils', + expected: null + }, + { + name: 'conflicting anchored names resolve to nothing', + title: 'OC | something… - grok', + expected: null + }, + { name: 'a bare Pi title anchors as Pi', title: 'pi', expected: 'pi' }, + { name: 'an OMP status title anchors as OMP', title: 'omp ready', expected: 'omp' }, + { + // Wrapper-frame π/OMP separators are handled by the synthetic-title path, not this + // evidence parser; pinned so a parser change here is a deliberate decision. + name: 'a π wrapper frame is declined by the evidence parser', + title: 'π : ready', + expected: null + } + ] + + for (const fixture of FIXTURES) { + it(fixture.name, () => { + expect(collectAgentTitleEvidence(fixture.title).agent).toBe(fixture.expected) + // The adapter's title-only lane must give the very same answer — phase 1 changes no + // parser semantics, only provenance. + expect(canonicalTitleOnlyAgent(fixture.title)).toBe(fixture.expected) + }) + } +}) + +describe('recorded title corpus characterization (local gate)', () => { + it('the canonical title-only lane matches the shipped parser on every recorded title', (ctx) => { + const corpus = loadRecordedTitleCorpus() + if (corpus === null) { + console.info('corpus unavailable — skipped (no recorded terminal history on this machine)') + ctx.skip() + return + } + // A machine WITH history must never pass on an empty read — that would be a silently green + // zero-title run, not a characterization. + expect(corpus.checkpointCount).toBeGreaterThan(0) + expect(corpus.titles.length).toBeGreaterThan(0) + console.info( + `corpus: ${corpus.checkpointCount} checkpoints, ${corpus.titles.length} distinct titles` + ) + + const salt = randomBytes(16).toString('hex') + const changed: { titleHash: string; oldAgent: string | null; newAgent: string | null }[] = [] + for (const title of corpus.titles) { + const oldAgent = collectAgentTitleEvidence(title).agent + const newAgent = canonicalTitleOnlyAgent(title) + if (oldAgent !== newAgent) { + changed.push({ + titleHash: createHash('sha256').update(`${salt}:${title}`).digest('hex').slice(0, 16), + oldAgent, + newAgent + }) + } + } + // Report hashes and agent summaries only; a reviewer who needs the raw value inspects the + // protected corpus on the machine that owns it. + expect(changed).toEqual([]) + }, 60_000) +}) diff --git a/src/shared/pane-agent-owner.test.ts b/src/shared/pane-agent-owner.test.ts index 13cfd217c45..b0d61801392 100644 --- a/src/shared/pane-agent-owner.test.ts +++ b/src/shared/pane-agent-owner.test.ts @@ -37,6 +37,42 @@ describe('resolvePaneAgentOwner', () => { ).toBe('omp') }) + it('preserves the pre-tranche precedence for every conflicting owner tier', () => { + expect( + resolvePaneAgentOwnerRecord({ + launchAgent: 'claude', + hookAgent: 'codex', + siblingHookAgent: 'gemini', + completedHookAgent: 'pi', + sleepingSessionAgent: 'omp' + }) + ).toEqual({ agent: 'claude', ownerIsLaunch: true }) + expect( + resolvePaneAgentOwnerRecord({ + hookAgent: 'claude', + siblingHookAgent: 'codex', + completedHookAgent: 'gemini', + siblingCompletedHookAgent: 'pi', + sleepingSessionAgent: 'omp' + }) + ).toEqual({ agent: 'claude', ownerIsLaunch: false }) + expect( + resolvePaneAgentOwnerRecord({ + siblingHookAgent: 'codex', + completedHookAgent: 'claude', + siblingCompletedHookAgent: 'gemini', + sleepingSessionAgent: 'omp' + }) + ).toEqual({ agent: 'codex', ownerIsLaunch: false }) + expect( + resolvePaneAgentOwnerRecord({ + completedHookAgent: 'claude', + siblingCompletedHookAgent: 'codex', + sleepingSessionAgent: 'gemini' + }) + ).toEqual({ agent: 'claude', ownerIsLaunch: false }) + }) + it('returns null when no owner evidence exists', () => { expect(resolvePaneAgentOwner({})).toBeNull() expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull() diff --git a/src/shared/pane-agent-owner.ts b/src/shared/pane-agent-owner.ts index 31b878d67ec..5b5563670e8 100644 --- a/src/shared/pane-agent-owner.ts +++ b/src/shared/pane-agent-owner.ts @@ -48,9 +48,10 @@ const PANE_OWNER_RANK: readonly { ] /** - * 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. + * Compatibility owner lookup shared by the existing consumer surfaces. + * + * Tranche 0 intentionally preserves this pre-migration precedence byte-for-byte; switching + * these consumers to canonical evidence belongs to tranche 1. * * Why this precedence: launch intent is the authoritative bootstrap before any * process signal exists, so it leads. Once launch metadata is gone — a mirrored diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index cdbce788806..4a000f8be9b 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -10,6 +10,7 @@ import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' import { memoizeTitleClassification } from './terminal-title-classification-memo' import type { TuiAgent } from './tui-agent' @@ -217,10 +218,10 @@ function computeAgentLabel(title: string): string | null { return null } -// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for -// agents whose foreground PROCESS name isn't self-identifying (Claude Code runs -// as `node`, but its "✳ Claude Code" title resolves here). Agents whose process -// name already matches (codex, etc.) never reach this path. +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) + const TITLE_LABEL_TO_AGENT: Partial> = { 'Claude Code': 'claude', OpenClaude: 'openclaude', @@ -240,10 +241,6 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } -/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ -export const getAgentLabel: (title: string) => string | null = - memoizeTitleClassification(computeAgentLabel) - function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsAgentSpinnerGlyph(title) || @@ -266,7 +263,13 @@ function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { const label = getAgentLabel(title) - return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null + const parsed = label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null + return resolveCanonicalPaneAgentIdentity({ + title, + // Preserve this public title-parser adapter's historical answer; pane identity + // consumers pass raw titles to the canonical resolver and enforce its fence. + uncoveredFallback: { agent: parsed, titleOnly: false } + }).agent } /** @@ -283,6 +286,6 @@ function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null { return titleAgent } -/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +/** Pure in `title` — memoized so repeated selector reads skip the canonical/title parse. */ export const resolveExplicitTerminalTitleAgentType: (title: string) => TuiAgent | null = memoizeTitleClassification(computeExplicitTerminalTitleAgentType)