Files
orca/src/shared/agent-detection.test.ts
T
Brennan BensonandMerge Sim e54cfc1901 fix(omp): read Pi/OMP static state-title markers and retire stale spinners (#14602)
* fix(omp): read Pi/OMP static state-title markers and retire stale spinners

OMP 17.2.12 replaced its animated braille title frames with static markers
on WSL/ConPTY (`π : working`, `π > idle`, `π ! needs input`). Orca read all
three as idle, so a working OMP pane lost its status, and a synthetic title
spinner started by an earlier hook kept rotating after its status row was
gone.

Classify the markers from one shared table so a later upstream punctuation
change is a row, not a reparse, and stop the spinner when the hook row it
stands in for is cleared or dismissed.

Fixes #13890

* test(omp): preserve static state titles during normalization

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-30 18:50:56 -07:00

317 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectAgentStatusFromTitle,
extractAllOscTitles,
extractLastOscTitle,
getAgentLabel,
isCursorAgentTitle,
MAX_OSC_TITLE_CHARS,
MAX_OSC_TITLES_PER_CHUNK,
normalizeTerminalTitle
} from './agent-detection'
import {
hasCompatibleAgentTitleIdentity,
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from './agent-title-owner'
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
afterEach(() => {
vi.restoreAllMocks()
})
describe('OSC title extraction', () => {
it('extracts the last OSC title from BEL-terminated PTY data', () => {
expect(extractLastOscTitle('\x1b]0;First\x07noise\x1b]2;Second\x07')).toBe('Second')
})
it('extracts all OSC titles including ST-terminated titles', () => {
expect(extractAllOscTitles('\x1b]0;First\x1b\\noise\x1b]2;Second\x07')).toEqual([
'First',
'Second'
])
})
it('ignores incomplete OSC titles until a later chunk supplies the terminator', () => {
expect(extractAllOscTitles('\x1b]0;Incomplete title')).toEqual([])
expect(extractLastOscTitle('\x1b]0;Incomplete title')).toBeNull()
})
it('recovers when an abandoned incomplete OSC title is followed by a fresh title', () => {
const data = '\x1b]0;abandoned\x1b]0;Fresh title\x07'
expect(extractLastOscTitle(data)).toBe('Fresh title')
expect(extractAllOscTitles(data)).toEqual(['Fresh title'])
})
it('scans large PTY chunks without regex match iteration', () => {
const matchAll = vi.spyOn(String.prototype, 'matchAll')
const data = `${'pasted terminal noise \x1b]x;ignored\x07 '.repeat(10_000)}\x1b]0;Agent working\x07`
expect(extractLastOscTitle(data)).toBe('Agent working')
expect(extractAllOscTitles(data).at(-1)).toBe('Agent working')
expect(matchAll).not.toHaveBeenCalled()
})
it('caps oversized OSC titles before downstream title processing', () => {
const title = `${'a'.repeat(MAX_OSC_TITLE_CHARS)}${'b'.repeat(10_000)}`
const data = `before\x1b]0;${title}\x07after`
const extracted = extractLastOscTitle(data)
expect(extracted).toHaveLength(MAX_OSC_TITLE_CHARS)
expect(extracted?.startsWith('a'.repeat(MAX_OSC_TITLE_CHARS / 2))).toBe(true)
expect(extracted?.endsWith('b'.repeat(MAX_OSC_TITLE_CHARS / 2))).toBe(true)
expect(extractAllOscTitles(data)).toEqual([extracted])
})
it('retains only the newest titles when one chunk contains limit +1', () => {
const data = Array.from(
{ length: MAX_OSC_TITLES_PER_CHUNK + 1 },
(_, index) => `\x1b]0;title-${index}\x07`
).join('')
const titles = extractAllOscTitles(data)
expect(titles).toHaveLength(MAX_OSC_TITLES_PER_CHUNK)
expect(titles[0]).toBe('title-1')
expect(titles.at(-1)).toBe(`title-${MAX_OSC_TITLES_PER_CHUNK}`)
})
})
describe('MiMo title detection', () => {
it.each([
['MiMo Code', 'idle'],
['mimo ready', 'idle'],
['mimo working', 'working'],
['\u280b MiMo Code', 'working']
] as const)('classifies %s', (title, expectedStatus) => {
expect(getAgentLabel(title)).toBe('MiMo Code')
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
})
it.each(['~/mimo/working', 'mimo-code-fixtures ready'])(
'does not classify path or hyphen false positive %s',
(title) => {
expect(getAgentLabel(title)).toBeNull()
expect(detectAgentStatusFromTitle(title)).toBeNull()
}
)
})
describe('OpenCode native title detection', () => {
// Why: `OC | …` names no agent token, so title-derived display and target surfaces
// previously dropped OpenCode panes. Runtime sends corroborate the title separately.
it.each(['OC | Implement the Kitty IME preview', 'tmux | OC | Implement the Kitty IME preview'])(
'classifies the native session title %j as title-derived idle OpenCode',
(title) => {
expect(getAgentLabel(title)).toBe('OpenCode')
expect(detectAgentStatusFromTitle(title)).toBe('idle')
}
)
// Why: a spinner glyph is the one status decoration OpenCode adds to the marker, and
// its gate runs before the marker's, so an animating frame still reads working (#8940).
it('reads a spinner-decorated native frame as working', () => {
const title = 'OC | ⠋ ask claude about this'
expect(getAgentLabel(title)).toBe('OpenCode')
expect(detectAgentStatusFromTitle(title)).toBe('working')
})
it.each(['OC | ✦ Gemini CLI', 'OC | ✋ review Gemini permission handling'])(
'does not let a Gemini glyph in OpenCode session text override identity for %j',
(title) => {
expect(getAgentLabel(title)).toBe('OpenCode')
expect(detectAgentStatusFromTitle(title)).toBe('idle')
}
)
// Why: the text after the marker is OpenCode's generated session summary — subject
// matter, not status. Routing it through the keyword gates would let an ordinary task
// name ("stop the flaky test") park a live pane in working/permission forever, so the
// marker asserts presence only. Do not "fix" these into keyword-derived statuses.
it.each([
'OC | ready to review',
'OC | permission prompt keeps reappearing',
'OC | waiting on the flaky test',
'OC | stop the suite from running'
])('keeps the status word inside the session summary %j inert', (title) => {
expect(getAgentLabel(title)).toBe('OpenCode')
expect(detectAgentStatusFromTitle(title)).toBe('idle')
})
it.each(['OC |', 'OC|Build', 'oc | lowercase lookalike', 'OCTOPUS | build'])(
'does not treat the lookalike title %j as an agent',
(title) => {
expect(detectAgentStatusFromTitle(title)).toBeNull()
}
)
})
describe('Pi-compatible title detection', () => {
it.each([
['\u280b OMP', 'OMP', 'working'],
['OMP ready', 'OMP', 'idle'],
['OMP', 'OMP', 'idle'],
['OMP - action required', 'OMP', 'permission'],
['\u280b Pi', 'Pi', 'working'],
['Pi ready', 'Pi', 'idle'],
// Why: titles stored by the old collapse are still bare "Pi"; re-detection
// from stored lastOscTitle must still classify idle, not neutral.
['Pi', 'Pi', 'idle'],
['Pi - action required', 'Pi', 'permission']
] as const)('classifies synthesized %s', (title, expectedLabel, expectedStatus) => {
expect(getAgentLabel(title)).toBe(expectedLabel)
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
})
// Why: OMP 17.2.12+ writes static state markers on WSL/ConPTY (#13890). The label after
// the marker is cwd/session text, so glyphs another agent uses for status must not win.
it.each([
['π : my-project', 'working', 'π : my-project'],
['π > my-project', 'idle', 'π > my-project'],
['π ! my-project', 'permission', 'π ! my-project'],
['π > gemini ✦ ⏲ ◇ ✋', 'idle', 'π > gemini ✦ ⏲ ◇ ✋'],
['zsh | π : my-project', 'working', 'zsh | π : my-project'],
['zsh | π > gemini ✦ ⏲ ◇ ✋', 'idle', 'zsh | π > gemini ✦ ⏲ ◇ ✋']
] as const)('classifies native OMP title %j as %s', (title, expectedStatus, expectedDisplay) => {
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
expect(normalizeTerminalTitle(title)).toBe(expectedDisplay)
})
it('re-detects status after display-title normalization for Pi idle frames', () => {
// Normalization now preserves the session name and cwd (#16093).
expect(normalizeTerminalTitle('π - my-project')).toBe('π - my-project')
expect(detectAgentStatusFromTitle(normalizeTerminalTitle('π - my-project'))).toBe('idle')
expect(detectAgentStatusFromTitle(normalizeTerminalTitle('\u280b π - my-project'))).toBe(
'working'
)
})
it.each([
['\u280b Pi', 'omp', '\u280b OMP'],
['Pi ready', 'omp', 'OMP ready'],
['Pi - action required', 'omp', 'OMP - action required'],
// Why: the brand is swapped for the owner's label in place — the pane still reads as its
// launch owner, but the session name and cwd it chose survive (#16093).
['π - tmp', 'omp', 'OMP - tmp'],
['π: tmp', 'omp', 'OMP: tmp'],
['\u280b π: tmp', 'omp', '\u280b OMP: tmp'],
['\u280b π - tmp', 'omp', '\u280b OMP - tmp'],
['\u280b OMP', 'pi', '\u280b Pi'],
[
'lucky-echidna | \u283c π - Diagnose Orca terminal title flicker - test',
'omp',
'lucky-echidna | \u283c OMP - Diagnose Orca terminal title flicker - test'
],
['lucky-echidna | Pi ready', 'omp', 'OMP ready'],
['Codex | Pi ready', 'omp', 'OMP ready'],
// Why: the wrapped whole reads as a braille Claude title, but the re-ownable
// synthetic pane suffix must still win.
['lucky-echidna | ⠋ OMP', 'omp', '⠋ OMP'],
[
'lucky-echidna | \u283c π - Diagnose | test',
'omp',
'lucky-echidna | \u283c OMP - Diagnose | test'
]
] as const)('normalizes %s to the authoritative %s owner', (title, owner, expectedTitle) => {
expect(normalizeCompatibleAgentTitleForOwner(title, owner)).toBe(expectedTitle)
})
it('preserves Pi-compatible custom titles and unrelated owners', () => {
expect(normalizeCompatibleAgentTitleForOwner('Fix pi bugs', 'omp')).toBe('Fix pi bugs')
// Why: a wrapped title with no re-ownable identity in any " | " segment
// passes through untouched instead of collapsing to the owner label.
expect(normalizeCompatibleAgentTitleForOwner('lucky-echidna | Fix pi bugs', 'omp')).toBe(
'lucky-echidna | Fix pi bugs'
)
expect(normalizeCompatibleAgentTitleForOwner('xxPi ready', 'omp')).toBe('xxPi ready')
expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi')
})
it('identifies titles whose compatible identity can be re-owned', () => {
expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true)
expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true)
expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true)
expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false)
expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false)
})
it('normalizes Pi-compatible status identity and terminal title to the owner', () => {
const status = normalizeCompatibleAgentStatusEntryForOwner(
{
state: 'working',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'pi',
paneKey: 'tab-1:leaf-1',
terminalTitle: '\u280b Pi',
stateHistory: []
},
'omp'
)
expect(status.agentType).toBe('omp')
expect(status.terminalTitle).toBe('\u280b OMP')
expect(resolveCompatibleAgentTypeForOwner('codex', 'omp')).toBe('codex')
})
it.each(['~/omp/working', 'omp-harness ready', '~/pi/working', 'pi-scratch ready'])(
'does not classify path or hyphen false positive %s',
(title) => {
expect(getAgentLabel(title)).toBeNull()
expect(detectAgentStatusFromTitle(title)).toBeNull()
}
)
})
describe('Cursor agent title identity', () => {
// Why: the accepted vocabulary is the set of labels Orca actually synthesizes for Cursor.
// Pin it to that profile so renaming a label there cannot silently drop @cursor to zero
// recipients (and desync the auto-Enter suppression that shares this predicate).
it('accepts every label Orca synthesizes for Cursor', () => {
const profile = SYNTHETIC_AGENT_TITLE_PROFILES.cursor
for (const label of [
profile.workingLabel,
`⠋ ${profile.workingLabel}`,
profile.idleLabel,
profile.permissionLabel
]) {
expect(isCursorAgentTitle(label)).toBe(true)
}
})
it.each([
'Cursor Agent',
' cursor agent ',
'⠋ Cursor Agent',
'⣿ Cursor Agent',
'Cursor ready',
'Cursor - action required'
])('accepts the native or Orca-synthesized Cursor title %j', (title) => {
expect(isCursorAgentTitle(title)).toBe(true)
})
// Why: "cursor" is ordinary editor vocabulary in another agent's task-summary title,
// so a whole-token name match is not Cursor identity.
it.each([
'⠋ fix the text cursor blink',
'✳ Fix the text cursor blink',
'. fix cursor position',
'* cursor rendering done',
'Terminal Cursor and Orca slows down',
'cursor-agent',
'cursor.exe',
'~/cursor-rules',
'',
null,
undefined
])('rejects the non-Cursor title %j', (title) => {
expect(isCursorAgentTitle(title)).toBe(false)
})
})