diff --git a/src/shared/agent-title-evidence.test.ts b/src/shared/agent-title-evidence.test.ts index ea6dcbfdf11..176937a500e 100644 --- a/src/shared/agent-title-evidence.test.ts +++ b/src/shared/agent-title-evidence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { collectAgentTitleEvidence } from './agent-title-evidence' +import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' const agentFor = (title: string) => collectAgentTitleEvidence(title).agent const reasonFor = (title: string) => collectAgentTitleEvidence(title).reason @@ -38,7 +39,30 @@ describe('collectAgentTitleEvidence', () => { const reverse = collectAgentTitleEvidence(`${b} and ${a}`) expect(forward.agent).toBe(reverse.agent) expect(forward.agent).toBeNull() - expect([...forward.freeTextNames].sort()).toEqual([...reverse.freeTextNames].sort()) + expect([...forward.freeTextNames].sort()).toEqual([a, b].sort()) + expect([...reverse.freeTextNames].sort()).toEqual([a, b].sort()) + }) + }) + + it.each([ + ['claude', 'claude'], + ['openclaude', 'openclaude'], + ['codex', 'codex'], + ['copilot', 'copilot'], + ['cursor', 'cursor'], + ['gemini', 'gemini'], + ['antigravity', 'antigravity'], + ['opencode', 'opencode'], + ['mimo', 'mimo-code'], + ['openclaw', 'openclaw'], + ['aider', 'aider'], + ['grok', 'grok'], + ['devin', 'devin'] + ] as const)('collects the free-text token %s without claiming identity', (token, agent) => { + expect(collectAgentTitleEvidence(`review the ${token} integration`)).toMatchObject({ + agent: null, + reason: 'free-text-only', + freeTextNames: [agent] }) }) @@ -89,6 +113,82 @@ describe('collectAgentTitleEvidence', () => { }) }) + it.each(ALL_TUI_AGENTS)('recognizes the canonical whole-title label for %s', (agent) => { + const title = TUI_AGENT_DISPLAY_NAMES[agent] + expect(collectAgentTitleEvidence(title)).toMatchObject({ + agent, + reason: 'anchored' + }) + }) + + it('does not treat an exact display name as free text', () => { + expect(collectAgentTitleEvidence('Grok').freeTextNames).toEqual([]) + }) + + it.each([ + ['Claude Code', 'claude'], + ['Gemini CLI', 'gemini'], + ['Agent Teams', 'claude-agent-teams'] + ] as const)('recognizes the emitted whole-title alias %s', (title, agent) => { + expect(agentFor(title)).toBe(agent) + expect(reasonFor(title)).toBe('anchored') + }) + + it.each([ + ['Codex ready', 'codex'], + ['Codex - action required', 'codex'], + ['Cursor ready', 'cursor'], + ['Droid - action required', 'droid'], + ['Hermes ready', 'hermes'], + ['Devin - action required', 'devin'], + ['Pi ready', 'pi'], + ['OMP - action required', 'omp'] + ] as const)('recognizes Orca-controlled synthetic title %s', (title, agent) => { + expect(agentFor(title)).toBe(agent) + expect(reasonFor(title)).toBe('anchored') + }) + + it.each([ + ['⠋ Cursor Agent', 'cursor'], + ['⠋ Pi idle', 'pi'], + ['⠋ OMP done', 'omp'] + ] as const)('recognizes the decorated identity frame %s', (title, agent) => { + expect(agentFor(title)).toBe(agent) + expect(reasonFor(title)).toBe('anchored') + }) + + it('does not invent synthetic titles for an opted-out profile', () => { + expect(agentFor('OpenCode ready')).toBeNull() + expect(reasonFor('OpenCode ready')).toBe('free-text-only') + }) + + it('reads identity from the innermost wrapper segment', () => { + expect(agentFor('zsh | ⠋ Claude Code')).toBe('claude') + expect(agentFor('ssh | tmux | Cursor Agent')).toBe('cursor') + expect(agentFor('ssh | tmux | OC | review the parser')).toBe('opencode') + expect(agentFor('zsh | Fix the Codex parser')).toBeNull() + }) + + it('bounds wrapper inspection while preserving innermost identity', () => { + const wrappers = Array.from({ length: 200 }, (_, index) => `wrapper-${index}`).join(' | ') + expect(agentFor(`${wrappers} | ⠋ Cursor Agent`)).toBe('cursor') + expect(agentFor(`${wrappers} | OC | review the parser`)).toBe('opencode') + }) + + it.each([ + ['codex.exe', 'codex'], + ['openclaude.cmd', 'openclaude'], + ['gemini.ps1', 'gemini'], + ['droid.cmd', 'droid'], + ['hermes.exe', 'hermes'], + ['agy.bat', 'antigravity'], + ['CODEX.EXE', 'codex'], + ['DROID.CMD', 'droid'] + ] as const)('recognizes the bare Windows launcher %s', (title, agent) => { + expect(agentFor(title)).toBe(agent) + expect(reasonFor(title)).toBe('anchored') + }) + it('produces no name evidence for an agent outside the token set', () => { // The token set is deliberately narrower than the agent union: short names like `omp` would // classify ordinary shell text. Such a title yields no evidence at all rather than a guess. @@ -107,17 +207,62 @@ describe('collectAgentTitleEvidence', () => { ) }) + it('does not treat an embedded Claude sigil as a vendor marker', () => { + expect(collectAgentTitleEvidence('task text ✳ decoration')).toEqual({ + vendorMarkers: [], + anchoredNames: [], + freeTextNames: [], + agent: null, + reason: 'no-evidence' + }) + }) + + it('recognizes a bare Claude sigil as a vendor marker', () => { + expect(collectAgentTitleEvidence('✳')).toEqual({ + vendorMarkers: ['claude'], + anchoredNames: [], + freeTextNames: [], + agent: 'claude', + reason: 'vendor-marker' + }) + }) + describe('conflicting evidence of the same class resolves to nothing', () => { it('declines two anchored names', () => { - expect(reasonFor('OC | something… - grok')).toBe('conflicting-anchored-names') + const evidence = collectAgentTitleEvidence('OC | something… - grok') + expect(evidence.agent).toBeNull() + expect(evidence.reason).toBe('conflicting-anchored-names') + expect([...evidence.anchoredNames].sort()).toEqual(['grok', 'opencode']) + }) + + it('keeps an anchored conflict ahead of a vendor marker', () => { + const evidence = collectAgentTitleEvidence('✳ | OC | something… - grok') + expect(evidence.agent).toBeNull() + expect(evidence.reason).toBe('conflicting-anchored-names') + expect([...evidence.anchoredNames].sort()).toEqual(['grok', 'opencode']) + expect(evidence.vendorMarkers).toEqual(['claude']) }) it('declines two vendor markers', () => { - expect(reasonFor('✳ ✦ two sigils')).toBe('conflicting-vendor-markers') + const evidence = collectAgentTitleEvidence('✳ ✦ two sigils') + expect(evidence.agent).toBeNull() + expect(evidence.reason).toBe('conflicting-vendor-markers') + expect([...evidence.vendorMarkers].sort()).toEqual(['claude', 'gemini']) }) }) it('declines a Claude management screen', () => { - expect(agentFor('claude agents')).toBeNull() + expect(collectAgentTitleEvidence('claude agents')).toEqual({ + vendorMarkers: [], + anchoredNames: [], + freeTextNames: [], + agent: null, + reason: 'no-evidence' + }) + }) + + it('requires whitespace before the owner suffix dash', () => { + expect(agentFor('task- codex')).toBeNull() + expect(reasonFor('task- codex')).toBe('free-text-only') }) }) diff --git a/src/shared/agent-title-evidence.ts b/src/shared/agent-title-evidence.ts index 6d8412d218c..bcb5271522f 100644 --- a/src/shared/agent-title-evidence.ts +++ b/src/shared/agent-title-evidence.ts @@ -13,7 +13,12 @@ import { } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' +import { + SYNTHETIC_AGENT_TITLE_AGENTS, + SYNTHETIC_AGENT_TITLE_PROFILES +} from './synthetic-agent-title' import type { TuiAgent } from './tui-agent' +import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' /** * Order-independent identity evidence from a terminal title. @@ -73,20 +78,13 @@ const PATTERN_NAMES: readonly (readonly [RegExp, TuiAgent])[] = [ [HERMES_AGENT_NAME_RE, 'hermes'] ] -/** - * Canonical display labels an agent may write as its whole title. Declared here rather than - * derived from the renderer catalog so this module stays free of UI-layer imports; the labels an - * agent actually emits are a title fact, not a presentation choice. - */ -const DISPLAY_LABELS: readonly (readonly [string, TuiAgent])[] = [ +/** Exact whole-title labels are strong evidence even for agents unsafe to match in free text. */ +const DISPLAY_LABELS = [ + ...ALL_TUI_AGENTS.map((agent) => [TUI_AGENT_DISPLAY_NAMES[agent].toLowerCase(), agent] as const), ['claude code', 'claude'], ['gemini cli', 'gemini'], - ['mimo code', 'mimo-code'], - ['github copilot', 'copilot'], - ['command code', 'command-code'], - ['prime agent', 'prime-agent'], ['agent teams', 'claude-agent-teams'] -] +] satisfies readonly (readonly [string, TuiAgent])[] const GEMINI_GLYPHS = [GEMINI_WORKING, GEMINI_SILENT_WORKING, GEMINI_IDLE, GEMINI_PERMISSION] @@ -96,6 +94,22 @@ const GEMINI_GLYPHS = [GEMINI_WORKING, GEMINI_SILENT_WORKING, GEMINI_IDLE, GEMIN * worktree name (`review-14600-codex`), which is a directory, not an owner declaration. */ const OWNER_SUFFIX_RE = /\s-\s+([A-Za-z][\w-]*)\s*$/ +const WINDOWS_LAUNCHER_SUFFIX_RE = /\.(?:exe|cmd|bat|ps1)$/i +const WRAPPER_SEPARATOR = ' | ' +const MAX_WRAPPER_EVIDENCE_SEGMENTS = 8 + +function getEvidenceTitleSegments(title: string): string[] { + const segments = [title] + let separatorIndex = title.lastIndexOf(WRAPPER_SEPARATOR) + while (separatorIndex >= 0 && segments.length < MAX_WRAPPER_EVIDENCE_SEGMENTS) { + const wrapped = title.slice(separatorIndex + WRAPPER_SEPARATOR.length).trim() + if (wrapped && !segments.includes(wrapped)) { + segments.push(wrapped) + } + separatorIndex = title.lastIndexOf(WRAPPER_SEPARATOR, separatorIndex - 1) + } + return segments +} function namesIn(text: string): TuiAgent[] { const found = new Set() @@ -117,78 +131,105 @@ function agentForBareName(text: string): TuiAgent | null { if (!trimmed) { return null } - const names = namesIn(trimmed) - if (names.length !== 1) { - return null - } - // Why the length check: the remainder must BE the name, not merely contain it. "agy" anchors; - // "fix the agy hook" does not, and neither does a hyphenated worktree name like "codex-split". const stripped = trimmed.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}]+$/u, '') - if (/^[\p{L}\p{N}]+$/u.test(stripped)) { - return names[0] - } // Why labels too: an agent may write its own display name as the entire title (`⠐ Claude Code`). // That is the same claim as a bare token, just spelled the way the vendor spells it. const label = DISPLAY_LABELS.find(([text]) => text === stripped.toLowerCase()) - return label ? label[1] : null + if (label) { + return label[1] + } + const bareToken = stripped.replace(WINDOWS_LAUNCHER_SUFFIX_RE, '') + const names = namesIn(bareToken) + // Why the length check: the remainder must BE the name, not merely contain it. "agy" anchors; + // "fix the agy hook" does not, and neither does a hyphenated worktree name like "codex-split". + return names.length === 1 && /^[\p{L}\p{N}]+$/u.test(bareToken) ? names[0] : null } -function collectVendorMarkers(title: string): TuiAgent[] { +function agentForSyntheticTitle(text: string): TuiAgent | null { + const normalized = text + .trim() + .replace(/^[^\p{L}\p{N}]+/u, '') + .toLowerCase() + for (const agent of SYNTHETIC_AGENT_TITLE_AGENTS) { + const profile = SYNTHETIC_AGENT_TITLE_PROFILES[agent] + if ( + profile.synthesizeTerminalTitle !== false && + [profile.workingLabel, profile.permissionLabel, profile.idleLabel].some( + (label) => normalized === label.toLowerCase() + ) + ) { + return agent + } + } + return null +} + +function collectVendorMarkers(title: string, segments: readonly string[]): TuiAgent[] { const markers = new Set() // Why prefix-only for Claude: the sigil marks the pane's own status line. The same character // inside task text is decoration, not a vendor emission. - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - markers.add('claude') - } if (GEMINI_GLYPHS.some((glyph) => title.includes(glyph))) { markers.add('gemini') } - if (isCursorNativeAgentTitle(title)) { - markers.add('cursor') + for (const segment of segments) { + if (segment.startsWith(`${CLAUDE_IDLE} `) || segment === CLAUDE_IDLE) { + markers.add('claude') + } + if (isCursorNativeAgentTitle(segment)) { + markers.add('cursor') + } } return [...markers] } -function collectAnchoredNames(title: string): TuiAgent[] { +function collectAnchoredNames(segments: readonly string[]): TuiAgent[] { const anchored = new Set() - // Why anchored and not a bare marker: the native envelope owns the whole title. Its session - // text routinely names other agents ("OC | QA the Cursor sidecar") without ceasing to be - // OpenCode, so a foreign name in that text must not be able to veto it. - if (isOpenCodeNativeTitle(title)) { - anchored.add('opencode') - } - - const suffix = OWNER_SUFFIX_RE.exec(title) - if (suffix) { - const agent = agentForBareName(suffix[1]) - if (agent) { - anchored.add(agent) + for (const segment of segments) { + // Why anchored and not a bare marker: the native envelope owns the whole wrapped pane title. + // Its session text may name other agents without changing the OpenCode owner. + if (isOpenCodeNativeTitle(segment)) { + anchored.add('opencode') } - } - // Why strip a leading vendor sigil first: `✳ agy` is a Claude-glyphed pane whose entire - // remainder is another agent's name — the strongest name evidence a title can carry. - const withoutSigil = title.startsWith(`${CLAUDE_IDLE} `) ? title.slice(CLAUDE_IDLE.length) : title - const bare = agentForBareName(withoutSigil) - if (bare) { - anchored.add(bare) - } + const suffix = OWNER_SUFFIX_RE.exec(segment) + if (suffix) { + const agent = agentForBareName(suffix[1]) + if (agent) { + anchored.add(agent) + } + } - // Why Antigravity gets a grammar: its models are named `Gemini `, so an agy pane's - // own title carries a whole `gemini` token. Read as identity-plus-model, the gemini token is - // metadata — which is the general rule, not an exception inside the Gemini detector. - if (AGY_AGENT_NAME_RE.test(title) || titleHasAgentName(title, 'antigravity')) { - if (/\bgemini\s+\d/i.test(title)) { + // Why strip a leading vendor sigil first: `✳ agy` is a Claude-glyphed pane whose entire + // remainder is another agent's name — the strongest name evidence a title can carry. + const withoutSigil = segment.startsWith(`${CLAUDE_IDLE} `) + ? segment.slice(CLAUDE_IDLE.length) + : segment + const bare = agentForBareName(withoutSigil) + if (bare) { + anchored.add(bare) + } + const synthetic = agentForSyntheticTitle(segment) + if (synthetic) { + anchored.add(synthetic) + } + + // Why Antigravity gets a grammar: its models are named `Gemini `, so an agy pane's + // own title carries a whole `gemini` token. Read as identity-plus-model, the gemini token is + // metadata — which is the general rule, not an exception inside the Gemini detector. + if ( + (AGY_AGENT_NAME_RE.test(segment) || titleHasAgentName(segment, 'antigravity')) && + /\bgemini\s+\d/i.test(segment) + ) { anchored.add('antigravity') } - } - const piCompatible = getPiCompatibleSyntheticAgentLabel(title) - if (piCompatible === 'Pi') { - anchored.add('pi') - } else if (piCompatible === 'OMP') { - anchored.add('omp') + const piCompatible = getPiCompatibleSyntheticAgentLabel(segment) + if (piCompatible === 'Pi') { + anchored.add('pi') + } else if (piCompatible === 'OMP') { + anchored.add('omp') + } } return [...anchored] @@ -202,8 +243,9 @@ export function collectAgentTitleEvidence(title: string): AgentTitleEvidence { return { ...empty, agent: null, reason: 'no-evidence' } } - const vendorMarkers = collectVendorMarkers(title) - const anchoredNames = collectAnchoredNames(title) + const segments = getEvidenceTitleSegments(title) + const vendorMarkers = collectVendorMarkers(title, segments) + const anchoredNames = collectAnchoredNames(segments) const anchoredSet = new Set(anchoredNames) const freeTextNames = namesIn(title).filter((agent) => !anchoredSet.has(agent)) const evidence = { vendorMarkers, anchoredNames, freeTextNames } as const diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index 61229cea850..ee8a7697b36 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -1,4 +1,5 @@ import type { AgentStatusState, AgentType } from './agent-status-types' +import type { TuiAgent } from './tui-agent' export type SyntheticAgentTitleProfile = { workingLabel: string @@ -9,6 +10,17 @@ export type SyntheticAgentTitleProfile = { synthesizeWorkingTitle?: boolean } +export const SYNTHETIC_AGENT_TITLE_AGENTS = [ + 'codex', + 'cursor', + 'opencode', + 'pi', + 'omp', + 'droid', + 'hermes', + 'devin' +] as const satisfies readonly TuiAgent[] + export const SYNTHETIC_AGENT_TITLE_PROFILES: Record = { codex: { workingLabel: 'Codex',