mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(agent-status): harden title evidence collection
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<TuiAgent>()
|
||||
@@ -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<TuiAgent>()
|
||||
// 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<TuiAgent>()
|
||||
|
||||
// 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 <n.n> <Name>`, 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 <n.n> <Name>`, 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
|
||||
|
||||
@@ -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<string, SyntheticAgentTitleProfile> = {
|
||||
codex: {
|
||||
workingLabel: 'Codex',
|
||||
|
||||
Reference in New Issue
Block a user