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
}