diff --git a/src/shared/pane-agent-identity-adapter.test.ts b/src/shared/pane-agent-identity-adapter.test.ts index ea52fe50477..d898cb9adcf 100644 --- a/src/shared/pane-agent-identity-adapter.test.ts +++ b/src/shared/pane-agent-identity-adapter.test.ts @@ -121,6 +121,21 @@ describe('uncovered compatibility lane', () => { 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', diff --git a/src/shared/pane-agent-identity-adapter.ts b/src/shared/pane-agent-identity-adapter.ts index 4bbd42ba647..c08e1b77dea 100644 --- a/src/shared/pane-agent-identity-adapter.ts +++ b/src/shared/pane-agent-identity-adapter.ts @@ -121,6 +121,15 @@ const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOUR /** 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, @@ -139,14 +148,12 @@ export function resolveCanonicalPaneAgentEvidence( input: PaneAgentIdentityInput ): PaneAgentIdentity { const superseded: PaneAgentEvidenceSource[] = [] - const floor = input.minimumSource - ? SOURCE_RANK.indexOf(input.minimumSource) - : Number.MAX_SAFE_INTEGER + 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 (SOURCE_RANK.indexOf(item.source) > floor) { + if (sourceRankIndex(item.source) > floor) { return false } if (isPaneAgentRunEligible(item.run, input.currentRun)) { @@ -219,11 +226,31 @@ export function resolveCanonicalPaneAgentIdentity( input.launchAgent || input.sleepingSessionAgent ) - const titleAgent = input.title ? collectAgentTitleEvidence(input.title).agent : null + 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 { diff --git a/src/shared/pane-agent-identity-resolver.test.ts b/src/shared/pane-agent-identity-resolver.test.ts index ee8f5c287bc..2f14a54976a 100644 --- a/src/shared/pane-agent-identity-resolver.test.ts +++ b/src/shared/pane-agent-identity-resolver.test.ts @@ -162,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 b6dd8acd2c8..4b03c5de16d 100644 --- a/src/shared/pane-agent-identity-resolver.ts +++ b/src/shared/pane-agent-identity-resolver.ts @@ -3,6 +3,7 @@ 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". diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 25a955b3954..4a000f8be9b 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -266,7 +266,9 @@ export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { const parsed = label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null return resolveCanonicalPaneAgentIdentity({ title, - uncoveredFallback: { agent: parsed, titleOnly: parsed !== null } + // 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 }