From f1511f325bc8ca776a795c5fdc73aa3f6ebeca9c Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 11:53:50 -0700 Subject: [PATCH] feat(agents): pane-identity canonical adapter, comparison telemetry, inventory ratchet (phase 1) Output-neutral first slice of the pane-identity consolidation: a parallel canonical resolver adapter with a per-pane coverage gate and process-proof contract, old-vs-new disagreement telemetry at the host summary and tab-icon surfaces, a byte-for-byte characterization of the frozen host adapter, and an extended consumer-inventory ratchet (helper census, surface markers, rebind audit) plus a recorded-title corpus gate. No surface changes what it displays and no wire content changes. --- src/main/runtime/orca-runtime.ts | 40 ++- .../lib/tab-agent-identity-comparison.test.ts | 120 ++++++++ .../src/lib/tab-agent-identity-comparison.ts | 139 +++++++++ src/renderer/src/lib/use-tab-agent.ts | 10 +- .../pane-agent-identity-adapter.test.ts | 245 ++++++++++++++++ src/shared/pane-agent-identity-adapter.ts | 257 +++++++++++++++++ .../pane-agent-identity-comparison.test.ts | 116 ++++++++ src/shared/pane-agent-identity-comparison.ts | 176 ++++++++++++ .../pane-agent-identity-inventory.test.ts | 162 ++++++++++- ...e-agent-identity-surface-inventory.test.ts | 270 ++++++++++++++++++ .../pane-agent-identity-title-corpus.test.ts | 156 ++++++++++ ...ne-agent-identity-characterization.test.ts | 175 ++++++++++++ ...ublished-pane-agent-identity-comparison.ts | 94 ++++++ 13 files changed, 1950 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/lib/tab-agent-identity-comparison.test.ts create mode 100644 src/renderer/src/lib/tab-agent-identity-comparison.ts create mode 100644 src/shared/pane-agent-identity-adapter.test.ts create mode 100644 src/shared/pane-agent-identity-adapter.ts create mode 100644 src/shared/pane-agent-identity-comparison.test.ts create mode 100644 src/shared/pane-agent-identity-comparison.ts create mode 100644 src/shared/pane-agent-identity-surface-inventory.test.ts create mode 100644 src/shared/pane-agent-identity-title-corpus.test.ts create mode 100644 src/shared/published-pane-agent-identity-characterization.test.ts create mode 100644 src/shared/published-pane-agent-identity-comparison.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 46c3c60565d..9ecb68bfe19 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -404,7 +404,7 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../shared/terminal-tab-types' -import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity' +import { comparePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity-comparison' import type { TuiAgent } from '../../shared/tui-agent' import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -35987,12 +35987,20 @@ export class OrcaRuntimeService { return null } - /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. */ + /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. + * Published output is the FROZEN resolver's, verbatim; the wrapper only counts where the + * canonical ladder would disagree (identity-ladder comparison window). */ private resolvePaneAgentIdentityField( launchAgent: TuiAgent | null | undefined, foregroundAgent: TuiAgent | null | undefined, title: string | null, - paneKey: string | null + paneKey: string | null, + comparison: { + surface: 'terminal-summary' | 'pty-terminal-summary' + paneId: string + worktreeId: string + remote: boolean + } ): { agentIdentity?: TuiAgent } { // Why hooks here: an agent the USER started from a shell has no launch record, and on WSL the // Windows host reads its foreground process as `wsl.exe` rather than the agent inside the @@ -36001,12 +36009,16 @@ export class OrcaRuntimeService { ? this.getHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) : null const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null - const agentIdentity = resolvePublishedPaneAgentIdentity({ + const agentIdentity = comparePublishedPaneAgentIdentity({ hookAgent, hookIsLive: hookRow?.agentIsLive, launchAgent, foregroundAgent, - title + title, + surface: comparison.surface, + paneId: comparison.paneId, + worktreeId: comparison.worktreeId, + hostScope: comparison.remote ? 'remote' : 'local' }) return agentIdentity ? { agentIdentity } : {} } @@ -36059,7 +36071,15 @@ export class OrcaRuntimeService { title, // Why guarded: makePaneKey THROWS on a non-UUID leaf id, and an unguarded call here took // down terminal.list for every pane in the list, not just the odd one. - isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : null + isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : null, + { + surface: 'terminal-summary', + paneId: `${leaf.tabId}:${leaf.leafId}`, + worktreeId: leaf.worktreeId, + remote: + leaf.ptyId !== null && + (leaf.ptyId.startsWith('remote:') || parseAppSshPtyId(leaf.ptyId) !== null) + } ) } } @@ -38232,7 +38252,13 @@ export class OrcaRuntimeService { pty.launchAgent, pty.foregroundAgent, title, - pty.paneKey ?? null + pty.paneKey ?? null, + { + surface: 'pty-terminal-summary', + paneId: pty.paneKey ?? `pty:${pty.ptyId}`, + worktreeId: pty.worktreeId, + remote: pty.ptyId.startsWith('remote:') || parseAppSshPtyId(pty.ptyId) !== null + } ) } } diff --git a/src/renderer/src/lib/tab-agent-identity-comparison.test.ts b/src/renderer/src/lib/tab-agent-identity-comparison.test.ts new file mode 100644 index 00000000000..b7b918a4193 --- /dev/null +++ b/src/renderer/src/lib/tab-agent-identity-comparison.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { PaneAgentIdentityComparisonRecorder } from '../../../shared/pane-agent-identity-comparison' +import { recordTabAgentLadderComparison } from './tab-agent-identity-comparison' +import { resolveTabAgentFromSignals } from './use-tab-agent' + +const GROK_ADVERSARIAL_TITLE = 'STA-4011 Linux Antigravity Commit Messages - grok' + +describe('tab-icon comparison lane', () => { + it('counts the title-above-hook reclaim that the rendered ladder allows and the canonical one refuses', () => { + // The rendered tab ladder consults the title before the completed hook, so a reused-looking + // title flips the icon. The canonical ladder keeps the completed hook until a run-key + // supersession proves a reclaim. This exact disagreement is what the window must surface. + const signals = { + hasObservedAgentSignal: true, + isRemote: false, + title: GROK_ADVERSARIAL_TITLE, + hookAgent: null, + focusedCompletedHookAgent: 'claude' as const, + launchAgent: undefined + } + const rendered = resolveTabAgentFromSignals(signals) + expect(rendered).toBe('grok') + + const recorder = new PaneAgentIdentityComparisonRecorder() + recordTabAgentLadderComparison( + { + tabId: 'tab-1', + worktreeId: 'wt-1', + isRemote: false, + title: signals.title, + hookAgent: signals.hookAgent, + focusedCompletedHookAgent: signals.focusedCompletedHookAgent, + launchAgent: null + }, + rendered, + recorder + ) + expect(recorder.snapshot()).toMatchObject({ + comparisons: 1, + disagreements: 1, + reclaimShapes: 1 + }) + }) + + it('agreement on a hook-covered pane records a comparison and no disagreement', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + recordTabAgentLadderComparison( + { + tabId: 'tab-2', + worktreeId: 'wt-1', + isRemote: false, + title: 'anything at all', + hookAgent: 'claude', + launchAgent: null + }, + 'claude', + recorder + ) + expect(recorder.snapshot()).toMatchObject({ comparisons: 1, disagreements: 0 }) + }) + + it('an uncovered pane preserves the rendered result as the compatibility lane', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + recordTabAgentLadderComparison( + { + tabId: 'tab-3', + worktreeId: 'wt-1', + isRemote: false, + title: GROK_ADVERSARIAL_TITLE, + hookAgent: null, + launchAgent: null + }, + 'grok', + recorder + ) + expect(recorder.snapshot()).toMatchObject({ + comparisons: 1, + disagreements: 0, + uncovered: 1 + }) + }) + + it('dedupes repeated renders of unchanged signals', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + const args = { + tabId: 'tab-4', + worktreeId: 'wt-1', + isRemote: false, + title: 'plain shell', + hookAgent: null, + launchAgent: null + } + recordTabAgentLadderComparison(args, null, recorder) + recordTabAgentLadderComparison(args, null, recorder) + expect(recorder.snapshot().comparisons).toBe(1) + }) + + it('a remote pane is recorded with remote host scope, never resolved differently', () => { + const emitted: Record[] = [] + const recorder = new PaneAgentIdentityComparisonRecorder((_line, detail) => { + if (detail && 'surface' in detail) { + emitted.push(detail) + } + }) + recordTabAgentLadderComparison( + { + tabId: 'tab-5', + worktreeId: 'wt-1', + isRemote: true, + title: GROK_ADVERSARIAL_TITLE, + hookAgent: null, + focusedCompletedHookAgent: 'claude', + launchAgent: null + }, + 'grok', + recorder + ) + expect(emitted[0]).toMatchObject({ hostScope: 'remote' }) + }) +}) diff --git a/src/renderer/src/lib/tab-agent-identity-comparison.ts b/src/renderer/src/lib/tab-agent-identity-comparison.ts new file mode 100644 index 00000000000..5303fcad9a4 --- /dev/null +++ b/src/renderer/src/lib/tab-agent-identity-comparison.ts @@ -0,0 +1,139 @@ +import { useEffect } from 'react' +import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence' +import { resolveCanonicalPaneAgentIdentity } from '../../../shared/pane-agent-identity-adapter' +import { + PaneAgentIdentityComparisonRecorder, + type PaneIdentityComparisonInput +} from '../../../shared/pane-agent-identity-comparison' +import type { TuiAgent } from '../../../shared/tui-agent' + +/** + * Tab-icon lane of the identity-ladder comparison window. The tab ladder is the one users + * actually see and the one that ranks a parsed title above the launch record; this wrapper + * computes the canonical answer beside the rendered one and counts where they disagree. The + * rendered result is untouched — the caller passes it in and keeps displaying it. + */ + +export type TabAgentLadderComparisonArgs = { + tabId: string + worktreeId?: string | null + isRemote: boolean + title: string + hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null + focusedCompletedHookAgent?: TuiAgent | null + siblingCompletedHookAgent?: TuiAgent | null + /** Renderer foreground hint — no host process proof exists, so the canonical lane treats it as + * weak evidence rather than the process rung. */ + processAgent?: TuiAgent | null + sleepingSessionAgent?: TuiAgent | null + launchAgent?: TuiAgent | null +} + +const defaultRecorder = new PaneAgentIdentityComparisonRecorder((line, sample) => { + console.info(`[pane-identity-compare] ${line}`, sample ?? {}) +}) + +export function getTabAgentLadderComparisonRecorder(): PaneAgentIdentityComparisonRecorder { + return defaultRecorder +} + +/** The tab's already-built ladder signals; a strict subset of `resolveTabAgentFromSignals` args. */ +export type TabAgentLadderSignals = { + isRemote: boolean + title: string + hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null + focusedCompletedHookAgent?: TuiAgent | null + siblingCompletedHookAgent?: TuiAgent | null + processAgent?: TuiAgent | null + sleepingSessionAgent?: TuiAgent | null + launchAgent?: TuiAgent | null +} + +/** Post-render on purpose: render stays pure, the rendered icon stays untouched, and the + * recorder's signature gate keeps repeat commits free. */ +export function useTabAgentLadderComparison( + tabId: string, + worktreeId: string | null | undefined, + signals: TabAgentLadderSignals, + renderedAgent: TuiAgent | null +): void { + useEffect(() => { + recordTabAgentLadderComparison( + { + tabId, + worktreeId, + isRemote: signals.isRemote, + title: signals.title, + hookAgent: signals.hookAgent, + siblingHookAgent: signals.siblingHookAgent, + focusedCompletedHookAgent: signals.focusedCompletedHookAgent, + siblingCompletedHookAgent: signals.siblingCompletedHookAgent, + processAgent: signals.processAgent, + sleepingSessionAgent: signals.sleepingSessionAgent, + launchAgent: signals.launchAgent ?? null + }, + renderedAgent + ) + }) +} + +export function recordTabAgentLadderComparison( + args: TabAgentLadderComparisonArgs, + renderedAgent: TuiAgent | null, + recorder: PaneAgentIdentityComparisonRecorder = defaultRecorder +): void { + try { + const signature = [ + args.hookAgent ?? '-', + args.siblingHookAgent ?? '-', + args.focusedCompletedHookAgent ?? '-', + args.siblingCompletedHookAgent ?? '-', + args.processAgent ?? '-', + args.sleepingSessionAgent ?? '-', + args.launchAgent ?? '-', + String(args.isRemote), + renderedAgent ?? '-', + args.title + ].join('|') + if (!recorder.shouldCompare('tab-icon', args.tabId, signature)) { + return + } + const canonical = resolveCanonicalPaneAgentIdentity({ + hookAgent: args.hookAgent, + hookIsLive: true, + completedHookAgent: args.focusedCompletedHookAgent, + launchAgent: args.launchAgent, + foregroundAgent: args.processAgent, + sleepingSessionAgent: args.sleepingSessionAgent, + siblingAgent: args.siblingHookAgent ?? args.siblingCompletedHookAgent, + allowSibling: true, + title: args.title, + uncoveredFallback: { agent: renderedAgent } + }) + const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null + const input: PaneIdentityComparisonInput = { + surface: 'tab-icon', + paneId: args.tabId, + worktreeId: args.worktreeId, + oldAgent: renderedAgent, + newAgent: canonical.agent, + newSource: canonical.source, + coverage: canonical.coverage, + titleOnly: canonical.titleOnly, + // Run keys reach the tab ladder with a later wave; absent means absent, not stale. + runKeyComparability: 'absent', + hostScope: args.isRemote ? 'remote' : 'local', + ambiguous: canonical.ambiguousAt !== undefined, + reclaimShape: Boolean( + args.focusedCompletedHookAgent && + titleAgent && + titleAgent !== args.focusedCompletedHookAgent + ) + } + recorder.record(input) + } catch { + // Comparison telemetry must never break the tab bar; a lost sample is recoverable. + } +} diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 21c00f4cc37..5153b38e4d7 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -19,6 +19,7 @@ import { import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' +import { useTabAgentLadderComparison } from './tab-agent-identity-comparison' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' @@ -337,7 +338,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { tab.title ]) - return resolveTabAgentFromSignals({ + const signals = { hasObservedAgentSignal, isRemote: isRemoteLike, title: tab.title, @@ -350,5 +351,10 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { processShellForeground, sleepingSessionAgent, launchAgent: tab.launchAgent - }) + } + const renderedAgent = resolveTabAgentFromSignals(signals) + // Identity-ladder comparison window (post-render): counts canonical-vs-rendered disagreement; + // the rendered icon stays untouched. + useTabAgentLadderComparison(tab.id, tab.worktreeId, signals, renderedAgent) + return renderedAgent } 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..883ab4675f5 --- /dev/null +++ b/src/shared/pane-agent-identity-adapter.test.ts @@ -0,0 +1,245 @@ +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' }) + }) +}) + +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' }) + 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..c9508c83e63 --- /dev/null +++ b/src/shared/pane-agent-identity-adapter.ts @@ -0,0 +1,257 @@ +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { + resolvePaneAgentIdentity, + type PaneAgentEvidence, + type PaneAgentEvidenceSource, + type PaneAgentRunKey +} from './pane-agent-identity-resolver' +import type { TuiAgent } from './tui-agent' + +/** + * The parallel adapter entry point around `resolvePaneAgentIdentity`. + * + * The frozen host adapter (`published-pane-agent-identity.ts`) stays untouched and keeps + * publishing exactly what it publishes today. This adapter computes the CANONICAL answer the + * migration will eventually ship — per-pane coverage, provenance sidecar, process-proof gating — + * so comparison telemetry can log where the two disagree on real sessions BEFORE any surface + * changes what it displays. Nothing user-visible reads this module's answer yet. + */ + +/** + * 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 + 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[] +} + +/** 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 only: hook, proven process, launch, sleeping + // session. A bare foreground name, a sibling, and a title never cover a pane. + const covered = Boolean( + input.hookAgent || + input.completedHookAgent || + processEvidence || + input.launchAgent || + input.sleepingSessionAgent + ) + const titleAgent = input.title ? collectAgentTitleEvidence(input.title).agent : null + + if (!covered) { + if (input.uncoveredFallback) { + const agent = input.uncoveredFallback.agent + const titleOnly = + input.uncoveredFallback.titleOnly ?? (agent !== null && agent === titleAgent) + return { + agent, + source: agent === null ? null : titleOnly ? 'title' : null, + coverage: 'uncovered', + titleOnly, + supersededSources: [] + } + } + return { + agent: titleAgent, + source: titleAgent ? 'title' : null, + coverage: 'uncovered', + titleOnly: titleAgent !== null, + supersededSources: [] + } + } + + const resolved = resolvePaneAgentIdentity({ + 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 }] : []), + ...(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', + 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-comparison.test.ts b/src/shared/pane-agent-identity-comparison.test.ts new file mode 100644 index 00000000000..64573d3410e --- /dev/null +++ b/src/shared/pane-agent-identity-comparison.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { + PaneAgentIdentityComparisonRecorder, + type PaneIdentityComparisonInput +} from './pane-agent-identity-comparison' + +function sample(overrides: Partial = {}): PaneIdentityComparisonInput { + return { + surface: 'terminal-summary', + paneId: 'tab-1:leaf-1', + worktreeId: 'wt-1', + oldAgent: 'claude', + newAgent: 'claude', + newSource: 'launch', + coverage: 'covered', + titleOnly: false, + runKeyComparability: 'absent', + hostScope: 'local', + ambiguous: false, + reclaimShape: false, + ...overrides + } +} + +describe('comparison counters', () => { + it('counts disagreements and both absence-transition directions separately', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + recorder.record(sample()) + recorder.record(sample({ oldAgent: 'codex', newAgent: 'claude' })) + recorder.record(sample({ oldAgent: null, newAgent: 'claude' })) + recorder.record(sample({ oldAgent: 'claude', newAgent: null, newSource: null })) + expect(recorder.snapshot()).toMatchObject({ + comparisons: 4, + disagreements: 3, + oldAbsentNewPresent: 1, + oldPresentNewAbsent: 1 + }) + }) + + it('counts ambiguity, reclaim shapes, title-only, and uncovered lanes', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + recorder.record(sample({ ambiguous: true })) + recorder.record(sample({ reclaimShape: true })) + recorder.record(sample({ titleOnly: true, coverage: 'uncovered' })) + expect(recorder.snapshot()).toMatchObject({ + ambiguous: 1, + reclaimShapes: 1, + titleOnly: 1, + uncovered: 1 + }) + }) +}) + +describe('sampling and bounds', () => { + it('skips consecutive identical input signatures per pane, and resumes on change', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + expect(recorder.shouldCompare('tab-icon', 'tab-1', 'sig-a')).toBe(true) + expect(recorder.shouldCompare('tab-icon', 'tab-1', 'sig-a')).toBe(false) + expect(recorder.shouldCompare('tab-icon', 'tab-2', 'sig-a')).toBe(true) + expect(recorder.shouldCompare('tab-icon', 'tab-1', 'sig-b')).toBe(true) + expect(recorder.shouldCompare('tab-icon', 'tab-1', 'sig-a')).toBe(true) + }) + + it('emits one detail record per distinct disagreement shape, hard-capped', () => { + const emitted: Record[] = [] + const recorder = new PaneAgentIdentityComparisonRecorder((_line, detail) => { + if (detail && 'surface' in detail) { + emitted.push(detail) + } + }) + recorder.record(sample({ oldAgent: 'codex' })) + recorder.record(sample({ oldAgent: 'codex' })) + expect(emitted).toHaveLength(1) + for (let i = 0; i < 100; i += 1) { + recorder.record(sample({ oldAgent: `agent-${i}` })) + } + expect(emitted.length).toBeLessThanOrEqual(41) + expect(recorder.snapshot().disagreements).toBe(102) + }) +}) + +describe('privacy contract', () => { + it('emitted records pseudonymize ids and never contain a title-like payload', () => { + const rawTitle = 'SECRET /Users/someone/private/path — do not leak' + const emitted: { line: string; detail?: Record }[] = [] + const recorder = new PaneAgentIdentityComparisonRecorder((line, detail) => { + emitted.push({ line, detail }) + }) + recorder.record( + sample({ paneId: 'raw-pane-id', worktreeId: 'raw-worktree-id', oldAgent: 'codex' }) + ) + expect(emitted.length).toBeGreaterThan(0) + for (const { line, detail } of emitted) { + const serialized = `${line} ${JSON.stringify(detail ?? {})}` + expect(serialized).not.toContain(rawTitle) + expect(serialized).not.toContain('raw-pane-id') + expect(serialized).not.toContain('raw-worktree-id') + expect(detail).not.toHaveProperty('title') + } + }) + + it('two recorders pseudonymize the same id differently (per-process salt)', () => { + const captured: string[] = [] + const capture = (_line: string, detail?: Record) => { + if (detail && typeof detail.pane === 'string') { + captured.push(detail.pane) + } + } + const a = new PaneAgentIdentityComparisonRecorder(capture) + const b = new PaneAgentIdentityComparisonRecorder(capture) + a.record(sample({ oldAgent: 'codex' })) + b.record(sample({ oldAgent: 'codex' })) + expect(captured).toHaveLength(2) + expect(captured[0]).not.toBe(captured[1]) + }) +}) diff --git a/src/shared/pane-agent-identity-comparison.ts b/src/shared/pane-agent-identity-comparison.ts new file mode 100644 index 00000000000..bc2d92d2ccf --- /dev/null +++ b/src/shared/pane-agent-identity-comparison.ts @@ -0,0 +1,176 @@ +import type { PaneAgentCoverage } from './pane-agent-identity-adapter' +import type { PaneAgentEvidenceSource } from './pane-agent-identity-resolver' + +/** + * Comparison telemetry for the identity-ladder migration: where do the old and canonical ladders + * DISAGREE on real sessions? Recorded BEFORE any surface changes what it displays, so each later + * flip is a measured decision instead of a guess. + * + * Privacy contract: emitted records carry pseudonymous salted ids, agent enum values, sources, + * coverage, and counters — never raw titles, prompts, commands, file paths, or tokens. + */ + +export type PaneIdentityComparisonSurface = 'terminal-summary' | 'pty-terminal-summary' | 'tab-icon' + +export type PaneIdentityRunKeyComparability = 'comparable' | 'incomparable' | 'absent' + +export type PaneIdentityHostScope = 'local' | 'remote' | 'unknown' + +export type PaneIdentityComparisonInput = { + surface: PaneIdentityComparisonSurface + /** Raw pane/tab identifier; pseudonymized before it reaches any emitted record. */ + paneId: string + worktreeId?: string | null + oldAgent: string | null + newAgent: string | null + newSource: PaneAgentEvidenceSource | null + coverage: PaneAgentCoverage + titleOnly: boolean + runKeyComparability: PaneIdentityRunKeyComparability + hostScope: PaneIdentityHostScope + /** The canonical resolver saw equally-ranked conflicting evidence. */ + ambiguous: boolean + /** The bug-versus-reclaim input shape: a completed hook naming A beside a title naming B. */ + reclaimShape: boolean +} + +export type PaneIdentityComparisonCounters = { + comparisons: number + disagreements: number + ambiguous: number + reclaimShapes: number + /** Flipping would turn a published absence into a presence — `groups.ts` reads absence as NO. */ + oldAbsentNewPresent: number + oldPresentNewAbsent: number + titleOnly: number + uncovered: number +} + +const MAX_PANE_SIGNATURES = 2048 +const MAX_DISAGREEMENT_KEYS = 40 +/** Counter snapshots go out on a log scale so a busy session cannot flood the sink. */ +const SNAPSHOT_AT = [100, 1_000, 10_000, 100_000, 1_000_000] + +function pseudonymize(salt: string, value: string): string { + // djb2: stable within one process, meaningless outside it. Pseudonymity, not secrecy — the raw + // id never leaves the process either way. + let hash = 5381 + const input = `${salt}:${value}` + for (let i = 0; i < input.length; i += 1) { + hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0 + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +export class PaneAgentIdentityComparisonRecorder { + private readonly counters: PaneIdentityComparisonCounters = { + comparisons: 0, + disagreements: 0, + ambiguous: 0, + reclaimShapes: 0, + oldAbsentNewPresent: 0, + oldPresentNewAbsent: 0, + titleOnly: 0, + uncovered: 0 + } + private readonly lastSignatureByPane = new Map() + private readonly emittedDisagreementKeys = new Set() + private readonly salt: string + + constructor( + private readonly emit: (line: string, sample?: Record) => void = () => {} + ) { + this.salt = globalThis.crypto.randomUUID() + } + + /** + * Consecutive-duplicate gate, cheap enough for a render/summary path: callers build a signature + * from the ladder INPUTS and skip the (costlier) canonical resolution when nothing changed. + */ + shouldCompare( + surface: PaneIdentityComparisonSurface, + paneId: string, + signature: string + ): boolean { + const key = `${surface}|${paneId}` + if (this.lastSignatureByPane.get(key) === signature) { + return false + } + this.lastSignatureByPane.set(key, signature) + while (this.lastSignatureByPane.size > MAX_PANE_SIGNATURES) { + const oldest = this.lastSignatureByPane.keys().next().value + if (oldest === undefined) { + break + } + this.lastSignatureByPane.delete(oldest) + } + return true + } + + record(input: PaneIdentityComparisonInput): void { + this.counters.comparisons += 1 + if (input.ambiguous) { + this.counters.ambiguous += 1 + } + if (input.reclaimShape) { + this.counters.reclaimShapes += 1 + } + if (input.titleOnly) { + this.counters.titleOnly += 1 + } + if (input.coverage === 'uncovered') { + this.counters.uncovered += 1 + } + const disagrees = input.oldAgent !== input.newAgent + if (disagrees) { + this.counters.disagreements += 1 + if (input.oldAgent === null) { + this.counters.oldAbsentNewPresent += 1 + } + if (input.newAgent === null) { + this.counters.oldPresentNewAbsent += 1 + } + this.emitDisagreement(input) + } + if (SNAPSHOT_AT.includes(this.counters.comparisons)) { + this.emit('pane-identity-compare counters', { ...this.counters }) + } + } + + snapshot(): PaneIdentityComparisonCounters { + return { ...this.counters } + } + + private emitDisagreement(input: PaneIdentityComparisonInput): void { + const key = [ + input.surface, + input.oldAgent ?? '-', + input.newAgent ?? '-', + input.newSource ?? '-', + input.coverage, + input.hostScope + ].join('|') + // One detail record per distinct disagreement shape, hard-capped; repeats only count. + if ( + this.emittedDisagreementKeys.has(key) || + this.emittedDisagreementKeys.size >= MAX_DISAGREEMENT_KEYS + ) { + return + } + this.emittedDisagreementKeys.add(key) + this.emit('pane-identity-compare disagreement', { + surface: input.surface, + pane: pseudonymize(this.salt, input.paneId), + ...(input.worktreeId ? { worktree: pseudonymize(this.salt, input.worktreeId) } : {}), + oldAgent: input.oldAgent, + newAgent: input.newAgent, + newSource: input.newSource, + coverage: input.coverage, + titleOnly: input.titleOnly, + runKeyComparability: input.runKeyComparability, + hostScope: input.hostScope, + ambiguous: input.ambiguous, + reclaimShape: input.reclaimShape + }) + } +} diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 6f8f66b92d9..a795d3d42e3 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -18,7 +18,16 @@ const HELPERS = [ 'resolveExplicitTerminalTitleAgentType', 'resolveCommittedTitleAgentType', 'resolvePaneAgentOwner', - 'resolveCompatibleAgentTypeForOwner' + 'resolveCompatibleAgentTypeForOwner', + 'classifyTitleActivity', + 'detectAgentStatusFromTitle', + 'resolveAgentTypeFromTerminalTitle', + 'resolvePaneAgentIdentity', + 'resolveCanonicalPaneAgentIdentity', + 'resolvePublishedPaneAgentIdentity', + 'comparePublishedPaneAgentIdentity', + 'recordTabAgentLadderComparison', + 'useTabAgentLadderComparison' ] as const const TEST_SUPPORT_PATHS = new Set([ @@ -242,6 +251,157 @@ 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.ts', 12], + ['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-adapter.ts', 2], + '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/renderer/src/lib/tab-agent-identity-comparison.ts', 2], + ['src/shared/published-pane-agent-identity-comparison.ts', 2] + ] + }, + { + helper: 'resolvePublishedPaneAgentIdentity', + classification: 'parser-implementation', + paths: ['src/shared/published-pane-agent-identity.ts'] + }, + { + helper: 'resolvePublishedPaneAgentIdentity', + classification: 'identity-consumer', + paths: [['src/shared/published-pane-agent-identity-comparison.ts', 2]] + }, + { + helper: 'comparePublishedPaneAgentIdentity', + classification: 'parser-implementation', + paths: ['src/shared/published-pane-agent-identity-comparison.ts'] + }, + { + helper: 'comparePublishedPaneAgentIdentity', + classification: 'identity-consumer', + paths: [['src/main/runtime/orca-runtime.ts', 2]] + }, + { + helper: 'recordTabAgentLadderComparison', + classification: 'parser-implementation', + paths: [['src/renderer/src/lib/tab-agent-identity-comparison.ts', 2]] + }, + { + helper: 'useTabAgentLadderComparison', + classification: 'parser-implementation', + paths: ['src/renderer/src/lib/tab-agent-identity-comparison.ts'] + }, + { + helper: 'useTabAgentLadderComparison', + classification: 'identity-consumer', + paths: [['src/renderer/src/lib/use-tab-agent.ts', 2]] } ] 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..bba535748c3 --- /dev/null +++ b/src/shared/pane-agent-identity-surface-inventory.test.ts @@ -0,0 +1,270 @@ +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/TerminalPane.tsx', + 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/use-terminal-pane-lifecycle.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.ts', marker: 'recordCurrentAuthorityObservation' }, + { row: 59, path: 'src/main/runtime/orca-runtime.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.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/orca-runtime.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.ts', 3], + ['src/renderer/src/store/slices/agent-status.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/published-pane-agent-identity-characterization.test.ts b/src/shared/published-pane-agent-identity-characterization.test.ts new file mode 100644 index 00000000000..e232f3f7959 --- /dev/null +++ b/src/shared/published-pane-agent-identity-characterization.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { PaneAgentIdentityComparisonRecorder } from './pane-agent-identity-comparison' +import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity' +import { comparePublishedPaneAgentIdentity } from './published-pane-agent-identity-comparison' +import type { TuiAgent } from './tui-agent' + +/** + * Output-neutral characterization of the FROZEN host publication adapter. The host wave may only + * replace the frozen call after this table and the real-session comparison window are green, so + * every evidence shape the adapter accepts is pinned here byte-for-byte. These are observations + * of current behavior, not aspirations — do not "fix" a row without the host-wave migration. + */ + +type Shape = { + name: string + args: { + hookAgent?: TuiAgent | null + hookIsLive?: boolean + launchAgent?: TuiAgent | null + foregroundAgent?: TuiAgent | null + title?: string | null + } + expected: TuiAgent | undefined +} + +const GROK_ADVERSARIAL_TITLE = 'STA-4011 Linux Antigravity Commit Messages - grok' + +const SHAPES: readonly Shape[] = [ + { name: 'nothing known', args: {}, expected: undefined }, + { + name: 'all null/empty values', + args: { hookAgent: null, launchAgent: null, foregroundAgent: null, title: '' }, + expected: undefined + }, + { name: 'live hook only', args: { hookAgent: 'claude', hookIsLive: true }, expected: 'claude' }, + { + name: 'completed hook only', + args: { hookAgent: 'codex', hookIsLive: false }, + expected: 'codex' + }, + { name: 'launch only', args: { launchAgent: 'gemini' }, expected: 'gemini' }, + { name: 'foreground only', args: { foregroundAgent: 'codex' }, expected: 'codex' }, + { name: 'unambiguous title only', args: { title: GROK_ADVERSARIAL_TITLE }, expected: 'grok' }, + { + name: 'free-text title mention only', + args: { title: 'Review the Claude session-history fix' }, + expected: undefined + }, + { + name: 'live hook beats foreground', + args: { hookAgent: 'claude', hookIsLive: true, foregroundAgent: 'codex' }, + expected: 'claude' + }, + { + name: 'foreground beats launch (frozen process rung, no proof required)', + args: { launchAgent: 'claude', foregroundAgent: 'codex' }, + expected: 'codex' + }, + { + name: 'launch beats completed hook', + args: { hookAgent: 'codex', hookIsLive: false, launchAgent: 'claude' }, + expected: 'claude' + }, + { + name: 'launch beats unambiguous title', + args: { launchAgent: 'claude', title: GROK_ADVERSARIAL_TITLE }, + expected: 'claude' + }, + { + name: 'completed hook beats unambiguous title', + args: { hookAgent: 'claude', hookIsLive: false, title: GROK_ADVERSARIAL_TITLE }, + expected: 'claude' + }, + { + name: 'duplicate agreeing sources', + args: { + hookAgent: 'claude', + hookIsLive: true, + launchAgent: 'claude', + foregroundAgent: 'claude', + title: 'claude' + }, + expected: 'claude' + }, + { + name: 'full conflict resolves to strongest', + args: { + hookAgent: 'claude', + hookIsLive: true, + launchAgent: 'gemini', + foregroundAgent: 'codex', + title: GROK_ADVERSARIAL_TITLE + }, + expected: 'claude' + } +] + +describe('frozen host adapter characterization', () => { + for (const shape of SHAPES) { + it(`publishes ${shape.expected ?? 'nothing'} for: ${shape.name}`, () => { + expect(resolvePublishedPaneAgentIdentity(shape.args)).toBe(shape.expected) + }) + } +}) + +describe('comparison wrapper output-neutrality', () => { + it('returns the frozen result verbatim for every characterized shape', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + for (const shape of SHAPES) { + const wrapped = comparePublishedPaneAgentIdentity( + { + ...shape.args, + surface: 'terminal-summary', + paneId: `pane:${shape.name}`, + worktreeId: 'wt', + hostScope: 'local' + }, + recorder + ) + expect(wrapped).toBe(resolvePublishedPaneAgentIdentity(shape.args)) + } + expect(recorder.snapshot().comparisons).toBe(SHAPES.length) + }) + + it('counts the frozen-vs-canonical divergence instead of publishing it', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + // The frozen ladder lets a bare foreground NAME outrank the launch record; the canonical + // ladder demands a host process proof for that rung. The published value must stay the + // frozen one while the disagreement is counted — that count is the migration gate. + const published = comparePublishedPaneAgentIdentity( + { + launchAgent: 'claude', + foregroundAgent: 'codex', + surface: 'terminal-summary', + paneId: 'pane:process-demotion', + worktreeId: 'wt', + hostScope: 'local' + }, + recorder + ) + expect(published).toBe('codex') + expect(recorder.snapshot()).toMatchObject({ comparisons: 1, disagreements: 1 }) + }) + + it('records the reclaim/bad-hook input shape separately', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + comparePublishedPaneAgentIdentity( + { + hookAgent: 'claude', + hookIsLive: false, + title: GROK_ADVERSARIAL_TITLE, + surface: 'terminal-summary', + paneId: 'pane:reclaim-shape', + worktreeId: 'wt', + hostScope: 'local' + }, + recorder + ) + expect(recorder.snapshot()).toMatchObject({ comparisons: 1, reclaimShapes: 1 }) + }) + + it('skips recomputation for consecutive identical pane inputs but still returns the frozen value', () => { + const recorder = new PaneAgentIdentityComparisonRecorder() + const args = { + launchAgent: 'claude' as const, + surface: 'terminal-summary' as const, + paneId: 'pane:dedupe', + worktreeId: 'wt', + hostScope: 'local' as const + } + expect(comparePublishedPaneAgentIdentity(args, recorder)).toBe('claude') + expect(comparePublishedPaneAgentIdentity(args, recorder)).toBe('claude') + expect(recorder.snapshot().comparisons).toBe(1) + }) +}) diff --git a/src/shared/published-pane-agent-identity-comparison.ts b/src/shared/published-pane-agent-identity-comparison.ts new file mode 100644 index 00000000000..91f1c27d8aa --- /dev/null +++ b/src/shared/published-pane-agent-identity-comparison.ts @@ -0,0 +1,94 @@ +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' +import { + PaneAgentIdentityComparisonRecorder, + type PaneIdentityComparisonSurface, + type PaneIdentityHostScope +} from './pane-agent-identity-comparison' +import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity' +import type { TuiAgent } from './tui-agent' + +/** + * Output-neutral wrapper for the host publication path. The FROZEN adapter still decides what is + * published — this function returns its result verbatim — while the canonical adapter's answer is + * computed beside it and only disagreements are counted. `RuntimeTerminalSummary.agentIdentity` + * must not change while the comparison window runs. + */ + +export type PublishedPaneAgentIdentityComparisonArgs = { + hookAgent?: TuiAgent | null + hookIsLive?: boolean + launchAgent?: TuiAgent | null + foregroundAgent?: TuiAgent | null + title?: string | null + surface: PaneIdentityComparisonSurface + paneId: string + worktreeId?: string | null + hostScope: PaneIdentityHostScope +} + +const defaultRecorder = new PaneAgentIdentityComparisonRecorder((line, sample) => { + console.info(`[pane-identity-compare] ${line}`, sample ?? {}) +}) + +export function getPublishedPaneIdentityComparisonRecorder(): PaneAgentIdentityComparisonRecorder { + return defaultRecorder +} + +export function comparePublishedPaneAgentIdentity( + args: PublishedPaneAgentIdentityComparisonArgs, + recorder: PaneAgentIdentityComparisonRecorder = defaultRecorder +): TuiAgent | undefined { + const published = resolvePublishedPaneAgentIdentity(args) + try { + recordCanonicalDivergence(args, published ?? null, recorder) + } catch { + // Telemetry must never take down terminal.list; a lost sample is recoverable. + } + return published +} + +function recordCanonicalDivergence( + args: PublishedPaneAgentIdentityComparisonArgs, + published: TuiAgent | null, + recorder: PaneAgentIdentityComparisonRecorder +): void { + const signature = [ + args.hookAgent ?? '-', + args.hookIsLive === true ? 'live' : 'idle', + args.launchAgent ?? '-', + args.foregroundAgent ?? '-', + args.title ?? '-' + ].join('|') + if (!recorder.shouldCompare(args.surface, args.paneId, signature)) { + return + } + // No host process PROOF exists yet, so the canonical lane sees the foreground name as a weak + // hint only. Where that alone flips the answer is precisely what this window measures. + const canonical = resolveCanonicalPaneAgentIdentity({ + hookAgent: args.hookAgent, + hookIsLive: args.hookIsLive, + launchAgent: args.launchAgent, + foregroundAgent: args.foregroundAgent, + title: args.title, + uncoveredFallback: { agent: published, titleOnly: published !== null } + }) + const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null + recorder.record({ + surface: args.surface, + paneId: args.paneId, + worktreeId: args.worktreeId, + oldAgent: published, + newAgent: canonical.agent, + newSource: canonical.source, + coverage: canonical.coverage, + titleOnly: canonical.titleOnly, + // Run keys are not plumbed into the publication path yet (host wave); absent, not stale. + runKeyComparability: 'absent', + hostScope: args.hostScope, + ambiguous: canonical.ambiguousAt !== undefined, + reclaimShape: Boolean( + args.hookAgent && args.hookIsLive !== true && titleAgent && titleAgent !== args.hookAgent + ) + }) +}