Files
orca/src/shared/agent-process-recognition.test.ts
T
EvgeniiandBrennan Benson 63af0bdd24 feat(agents): add Prime Agent as a supported TUI agent with session history (#12935)
* feat(agents): add Prime Agent as a supported TUI agent with session history

Wire Prime Intellect's prime-agent CLI (a Pi fork) into the desktop and
mobile agent catalogs following the Trae registration pattern, and into
the Agent Session History browser following the OMP pattern:

- types.ts, tui-agent-config.ts: register 'prime-agent' with argv prompt
  injection behind a `--` separator (its own help documents `--` as
  "treat all following arguments as messages"; without it, prompts
  starting with `help`/`agents`/`-…` dispatch as subcommands or
  flags), plus csi-u Shift+Enter encoding matching the Pi TUI it embeds.
- agent-kind.ts, telemetry-events.ts, agent-status-types.ts,
  agent-type-label.ts, tui-agent-display-names.ts,
  tui-agent-selection.ts, skills-cli-agent-keys.ts: standard per-agent
  registrations.
- agent-headless-command.ts: `-p/--print` one-shot runs share the
  print-mode matcher with Claude/Trae so they are not mistaken for live
  interactive panes.
- agent-process-recognition: the npm shim launches a generic bundled
  cli.js, so only the exact package path is an authoritative identity
  (same as Pi and cursor-agent). The three per-agent regex branches are
  now one table in agent-node-entrypoint-identities.ts — the module was
  at its max-lines budget and a table makes the next agent one entry.
- AI Vault: sessions are Pi's message-graph JSONL under
  ~/.prime/agent/sessions (override PRIME_AGENT_CODING_AGENT_DIR —
  Prime Agent brands Pi's env contract instead of sharing
  PI_CODING_AGENT_DIR); parsed by the shared message-graph parser with
  incremental append-resume; discovered locally, in WSL homes, and over
  remote SSH; resumes by absolute transcript path
  (`prime-agent --resume <path>`) like OMP, with session-id fallback.
- skill-discovery-sources.ts: ~/.prime/agent/skills home source.
- Catalog, i18n (en/es/ja/ko/zh), mobile registries, and a bundled
  64x64 favicon (required by mobile's offline-icon invariant).

Scanner-test fixtures for OMP and Prime Agent move into
session-scanner-test-fixtures.ts and the incremental fixture into its
own module, keeping every touched file inside its max-lines budget
without ratchet bumps.

* fix(ai-vault): map custom Prime Agent roots to their sessions child

PRIME_AGENT_CODING_AGENT_DIR is consumed verbatim by the CLI as its agent
config dir, with transcripts always in <agentDir>/sessions — unlike
PI_CODING_AGENT_DIR's <home>/agent/sessions shape the shared normalizer
models. A custom root with a non-special basename (or a `.prime` leaf)
was therefore scanned as-is instead of its sessions child. Dedicated
normalizePrimeAgentSessionsDir appends `sessions` to every configured
root, taking only an explicit `.../sessions` path as-is; the shared
Pi/OMP normalizer drops the `.prime` widening it no longer needs.

Raised in review on #12935.

* fix(ai-vault): guard degenerate Prime Agent roots and cover the remote source

normalizePrimeAgentSessionsDir stripped a filesystem-root value ('/' or '//')
to '', which then joined into the relative root 'sessions' and would walk the
main-process cwd. session-scanner-roots.ts already carries this guard for the
OMP variant; apply the same fallback here.

The remote SSH source had no test: deleting jsonlSource('prime-agent', ...)
left the suite green, unlike the local path which is pinned by the
AI_VAULT_AGENTS exhaustiveness assertion in session-scanner.test.ts. Add a
case that fixes the .prime/agent/sessions root segments, the .jsonl
extension, and parser routing.

Raised in review on #12935.

* fix(ai-vault): honor Prime Agent's sessions-root env and non-interactive modes

Verified against upstream PrimeIntellect-ai/prime-agent source rather than
inferred from the CLI's help text.

config.ts getSessionsDir() reads PRIME_AGENT_SESSION_DIR (and its legacy
PRIME_AGENT_CODING_AGENT_SESSION_DIR alias) ahead of the agent dir and uses it
verbatim; setting either left the vault silently empty. It also appends
`sessions` to the agent dir unconditionally, with no basename escape hatch, so
PRIME_AGENT_CODING_AGENT_DIR=/data/sessions writes to /data/sessions/sessions
while Orca scanned /data/sessions. getAgentDir() and the session-dir override
both run through expandTildePath, so a `~` value set outside a shell resolves.

cli/args.ts also spells the non-interactive runs `--mode json|rpc|acp|daemon`,
which the shared print-mode matcher does not know, so those panes were counted
as live interactive agents and the paste-submit path would write user text into
a JSON-RPC/ACP stream. Match upstream exactly: only the space-separated form,
since `--mode=json` is not parsed by the CLI and does start the TUI.

Raised in review on #12935.

* fix(ai-vault): keep Prime Agent roots absolute and remote segments posix

Two holes in the previous commit.

The degenerate-root guard only rejected pure-separator values, so a relative
env value still resolved against the main-process cwd:
PRIME_AGENT_CODING_AGENT_DIR='.' scanned '<cwd>/sessions' and, worse,
PRIME_AGENT_SESSION_DIR='.' scanned the cwd itself. Require an absolute path in
both branches and fall back to the default otherwise.

remotePrimeAgentSessionsSegments() built its segments with the local-platform
join, so on a Windows client scanning a posix SSH host it produced
'\.prime\agent\sessions' and split('/') collapsed it to one bogus segment —
remote discovery would have found nothing. Remote roots are posix regardless of
client platform, so keep them literal. Pi and OMP are unaffected: their
normalizer returns a '.../sessions' input unchanged and never joins.

Raised in review on #12935.

* test(ai-vault): pin Windows drive roots to the Prime Agent default fallback

'C:\' and 'C:/' strip to the drive-relative 'C:', which isAbsolute
rejects on every platform — assert they land in the default fallback so
a looser truthiness check can't reintroduce a 'C:sessions' scan root.

Raised in review on #12935.

* test(ai-vault): pin the drive-relative root form and state what the posix runner can assert

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-09 14:50:02 -07:00

370 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
isRecognizedAgentType,
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
} from './agent-process-recognition'
describe('agent process recognition', () => {
it('recognizes packaged Codex foreground process names', () => {
expect(recognizeAgentProcess('codex-aarch64-ap')).toEqual({
agent: 'codex',
processName: 'codex-aarch64-ap'
})
expect(isRecognizedAgentType('codex-aarch64-ap')).toBe(true)
})
it('recognizes the OpenClaude foreground process', () => {
expect(recognizeAgentProcess('/usr/local/bin/openclaude')).toEqual({
agent: 'openclaude',
processName: 'openclaude'
})
expect(isRecognizedAgentType('openclaude')).toBe(true)
expect(isExpectedAgentProcess('/usr/local/bin/openclaude', 'claude')).toBe(false)
})
it('recognizes the Droid foreground process on Windows', () => {
expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\droid.cmd`)).toEqual({
agent: 'droid',
processName: 'droid'
})
})
it('matches expected agents from platform-specific foreground process paths', () => {
expect(recognizeAgentProcess('claude')).toEqual({
agent: 'claude',
processName: 'claude'
})
expect(
isExpectedAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\claude.exe`, 'claude')
).toBe(true)
expect(isExpectedAgentProcess('/usr/local/bin/claude', 'claude')).toBe(true)
expect(isExpectedAgentProcess('powershell.exe', 'claude')).toBe(false)
})
it('does not recognize Claude print-mode hook subprocesses as interactive agents', () => {
expect(
recognizeAgentProcessFromCommandLine(
'claude --print --model haiku "Analyze this conversation and determine: Does the assistant have more autonomous work to do RIGHT NOW?"'
)
).toBeNull()
expect(
recognizeAgentProcessFromCommandLine(
String.raw`/home/dev/.local/bin/claude -p "Context: This summary will be shown in a list"`
)
).toBeNull()
expect(
recognizeAgentProcessFromCommandLine(
String.raw`C:\Users\dev\AppData\Roaming\npm\claude.exe --output-format=json "hook prompt"`
)
).toBeNull()
expect(recognizeAgentProcessFromCommandLine('claude --resume abc123')).toEqual({
agent: 'claude',
processName: 'claude'
})
})
it('recognizes Command Code without classifying Windows cmd.exe as an agent', () => {
expect(recognizeAgentProcess('command-code')).toEqual({
agent: 'command-code',
processName: 'command-code'
})
expect(
recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\command-code.cmd`)
).toEqual({
agent: 'command-code',
processName: 'command-code'
})
expect(isRecognizedAgentType('command-code')).toBe(true)
expect(isRecognizedAgentType('cmd.exe')).toBe(false)
expect(recognizeAgentProcess('cmd.exe')).toBeNull()
})
it('recognizes Ante without classifying ante-prefixed path fragments as the agent', () => {
expect(recognizeAgentProcess('ante')).toEqual({
agent: 'ante',
processName: 'ante'
})
expect(recognizeAgentProcess('/Users/dev/.ante/bin/ante')).toEqual({
agent: 'ante',
processName: 'ante'
})
expect(isExpectedAgentProcess('/Users/dev/.ante/bin/ante', 'ante')).toBe(true)
expect(isRecognizedAgentType('ante')).toBe(true)
// Why: 'ante' is a common token in directory and binary names; only the
// exact normalized basename may classify as the agent.
expect(recognizeAgentProcess('ante-obsidian')).toBeNull()
expect(recognizeAgentProcess('antechamber')).toBeNull()
expect(isExpectedAgentProcess('ante-obsidian', 'ante')).toBe(false)
})
it('does not recognize Ante headless one-shot commands as interactive agents', () => {
expect(recognizeAgentProcessFromCommandLine('ante -p "summarize this diff"')).toBeNull()
expect(recognizeAgentProcessFromCommandLine('ante -psummarize')).toBeNull()
expect(
recognizeAgentProcessFromCommandLine('ante --prompt "review this for security issues"')
).toBeNull()
expect(
recognizeAgentProcessFromCommandLine('ante --prompt=review --output-format minimal')
).toBeNull()
expect(recognizeAgentProcessFromCommandLine('ante --resume ses_123')).toEqual({
agent: 'ante',
processName: 'ante'
})
})
it('does not recognize wrapped Ante headless one-shot commands as interactive agents', () => {
expect(
recognizeAgentProcessFromCommandLine('node /Users/dev/.ante/bin/ante --prompt "review"')
).toBeNull()
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node C:\Users\dev\.ante\bin\ante.cmd -p review`
)
).toBeNull()
expect(recognizeAgentProcessFromCommandLine('node /Users/dev/.ante/bin/ante')).toEqual({
agent: 'ante',
processName: 'ante'
})
})
it('recognizes Trae by its traecli binary, not the ambiguous trae-cli name', () => {
expect(recognizeAgentProcess('traecli')).toEqual({
agent: 'trae',
processName: 'traecli'
})
expect(recognizeAgentProcess('/Users/dev/.local/bin/traecli')).toEqual({
agent: 'trae',
processName: 'traecli'
})
expect(isExpectedAgentProcess('/Users/dev/.local/bin/traecli', 'traecli')).toBe(true)
expect(isRecognizedAgentType('traecli')).toBe(true)
// Why: `trae-cli` and `trae-agent` both name the unrelated open-source bytedance/trae-agent.
expect(recognizeAgentProcess('trae-cli')).toBeNull()
expect(recognizeAgentProcess('trae-agent')).toBeNull()
})
it('does not recognize Trae headless one-shot commands as interactive agents', () => {
expect(recognizeAgentProcessFromCommandLine('traecli -p "summarize this diff"')).toBeNull()
expect(recognizeAgentProcessFromCommandLine('traecli --print "review this"')).toBeNull()
expect(
recognizeAgentProcessFromCommandLine('traecli --output-format json "review this"')
).toBeNull()
expect(
recognizeAgentProcessFromCommandLine('traecli --output-format=stream-json review')
).toBeNull()
expect(recognizeAgentProcessFromCommandLine('traecli --resume AUTO')).toEqual({
agent: 'trae',
processName: 'traecli'
})
// Why: past `--` nothing is a flag, so this is the interactive pane Orca itself launches.
expect(recognizeAgentProcessFromCommandLine('traecli -- "--print the release notes"')).toEqual({
agent: 'trae',
processName: 'traecli'
})
})
it('recognizes Mistral Vibe by its installed executable and legacy alias', () => {
expect(recognizeAgentProcess('/home/dev/.local/bin/vibe')).toEqual({
agent: 'mistral-vibe',
processName: 'vibe'
})
expect(recognizeAgentProcess('mistral-vibe')).toEqual({
agent: 'mistral-vibe',
processName: 'mistral-vibe'
})
expect(isRecognizedAgentType('vibe')).toBe(true)
})
it('recognizes Qwen Code by its installed qwen executable', () => {
expect(recognizeAgentProcess('/home/dev/.local/bin/qwen')).toEqual({
agent: 'qwen-code',
processName: 'qwen'
})
expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\qwen.cmd`)).toEqual({
agent: 'qwen-code',
processName: 'qwen'
})
expect(isExpectedAgentProcess('/usr/local/bin/qwen', 'qwen')).toBe(true)
expect(isRecognizedAgentType('qwen')).toBe(true)
})
it('recognizes agent CLIs launched through interpreter wrappers', () => {
expect(
recognizeAgentProcessFromCommandLine('node /Users/dev/.nvm/versions/node/bin/codex')
).toEqual({ agent: 'codex', processName: 'codex' })
expect(
recognizeAgentProcessFromCommandLine('node /Users/dev/.nvm/versions/node/bin/gemini')
).toEqual({ agent: 'gemini', processName: 'gemini' })
expect(recognizeAgentProcessFromCommandLine('python3 /opt/homebrew/bin/hermes --tui')).toEqual({
agent: 'hermes',
processName: 'hermes'
})
expect(
recognizeAgentProcessFromCommandLine('python3.12 /opt/homebrew/bin/hermes --tui')
).toEqual({
agent: 'hermes',
processName: 'hermes'
})
expect(recognizeAgentProcessFromCommandLine('python -m aider')).toEqual({
agent: 'aider',
processName: 'aider'
})
expect(
recognizeAgentProcessFromCommandLine(
String.raw`python C:\Users\dev\AppData\Roaming\Python\Python312\Scripts\aider.py`
)
).toEqual({ agent: 'aider', processName: 'aider' })
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node C:\Users\dev\AppData\Roaming\npm\codex.cmd`
)
).toEqual({ agent: 'codex', processName: 'codex' })
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node C:\Users\dev\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js`
)
).toEqual({ agent: 'codex', processName: 'codex' })
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node C:\Users\dev\AppData\Roaming\npm\node_modules\@google\gemini-cli\bundle\gemini.mjs`
)
).toEqual({ agent: 'gemini', processName: 'gemini' })
})
it.each(['earendil-works', 'mariozechner'])('recognizes the @%s Pi npm entrypoint', (scope) => {
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node.exe C:\Users\dev\AppData\Roaming\npm\node_modules\@${scope}\pi-coding-agent\dist\cli.js`
)
).toEqual({ agent: 'pi', processName: 'pi' })
})
it('recognizes Prime Agent by its binary and npm entrypoint', () => {
expect(recognizeAgentProcess('prime-agent')).toEqual({
agent: 'prime-agent',
processName: 'prime-agent'
})
expect(recognizeAgentProcess('/opt/homebrew/bin/prime-agent')).toEqual({
agent: 'prime-agent',
processName: 'prime-agent'
})
expect(
recognizeAgentProcessFromCommandLine(
'node /opt/homebrew/lib/node_modules/prime-agent/dist/bundle/cli.js'
)
).toEqual({ agent: 'prime-agent', processName: 'prime-agent' })
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node.exe C:\Users\dev\AppData\Roaming\npm\node_modules\prime-agent\dist\bundle\cli.js`
)
).toEqual({ agent: 'prime-agent', processName: 'prime-agent' })
})
it('does not recognize Prime Agent headless one-shot commands as interactive agents', () => {
expect(recognizeAgentProcessFromCommandLine('prime-agent -p "summarize this diff"')).toBeNull()
expect(recognizeAgentProcessFromCommandLine('prime-agent --print "review this"')).toBeNull()
expect(recognizeAgentProcessFromCommandLine('prime-agent --resume abc123')).toEqual({
agent: 'prime-agent',
processName: 'prime-agent'
})
// Why: past `--` nothing is a flag, so this is the interactive pane Orca itself launches.
expect(
recognizeAgentProcessFromCommandLine('prime-agent -- "--print the release notes"')
).toEqual({ agent: 'prime-agent', processName: 'prime-agent' })
})
it('does not recognize Prime Agent non-interactive --mode runs as interactive agents', () => {
for (const mode of ['json', 'rpc', 'acp', 'daemon']) {
expect(recognizeAgentProcessFromCommandLine(`prime-agent --mode ${mode}`)).toBeNull()
}
// Why: `text` is the interactive TUI mode Orca hosts.
expect(recognizeAgentProcessFromCommandLine('prime-agent --mode text')).toEqual({
agent: 'prime-agent',
processName: 'prime-agent'
})
// Why: the CLI only parses `--mode <value>` as separate tokens, so `--mode=json`
// is ignored by it and still starts the interactive mode.
expect(recognizeAgentProcessFromCommandLine('prime-agent --mode=json')).toEqual({
agent: 'prime-agent',
processName: 'prime-agent'
})
expect(recognizeAgentProcessFromCommandLine('prime-agent -- --mode rpc')).toEqual({
agent: 'prime-agent',
processName: 'prime-agent'
})
})
it('recognizes only the agent subcommand of the generic Orca CLI', () => {
expect(recognizeAgentProcessFromCommandLine('orca claude-teams')).toEqual({
agent: 'claude-agent-teams',
processName: 'orca'
})
expect(recognizeAgentProcessFromCommandLine('orca status')).toBeNull()
expect(recognizeAgentProcessFromCommandLine('orca-dev terminal list')).toBeNull()
expect(recognizeAgentProcessFromCommandLine('node /usr/local/bin/orca claude-teams')).toEqual({
agent: 'claude-agent-teams',
processName: 'orca'
})
expect(recognizeAgentProcessFromCommandLine('node /usr/local/bin/orca status')).toBeNull()
})
it('recognizes the versioned Cursor Node wrapper without accepting generic agent processes', () => {
const cursorEntrypoint = String.raw`C:\Users\dev\AppData\Local\cursor-agent\versions\2026.07.09-a3815c0\index.js`
expect(recognizeAgentProcessFromCommandLine(`node.exe ${cursorEntrypoint}`)).toEqual({
agent: 'cursor',
processName: 'cursor-agent'
})
expect(
recognizeAgentProcessFromCommandLine(`node.exe ${cursorEntrypoint} worker-server`)
).toEqual({ agent: 'cursor', processName: 'cursor-agent' })
expect(
recognizeAgentProcessFromCommandLine(String.raw`node.exe C:\repo\cursor-agent\index.js`)
).toBeNull()
expect(
recognizeAgentProcessFromCommandLine(String.raw`C:\Users\dev\.grok\bin\agent.exe`)
).toBeNull()
})
it('does not classify prompt text as a wrapped agent command', () => {
expect(
recognizeAgentProcessFromCommandLine(
'node /tmp/not-an-agent.js "compare opencode vs orca in Gemini CLI"'
)
).toBeNull()
expect(recognizeAgentProcessFromCommandLine(String.raw`node C:\tmp\not-an-agent.js`)).toBeNull()
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node C:\repo\server.js --plugin C:\tmp\codex.js`
)
).toBeNull()
expect(recognizeAgentProcessFromCommandLine(String.raw`node C:\repo\codex.js`)).toBeNull()
expect(recognizeAgentProcessFromCommandLine(String.raw`node C:\repo\gemini.mjs`)).toBeNull()
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node C:\repo\node_modules\@example\pi-coding-agent\dist\cli.js`
)
).toBeNull()
expect(recognizeAgentProcessFromCommandLine(String.raw`python C:\repo\aider.py`)).toBeNull()
expect(recognizeAgentProcessFromCommandLine('python -m not_aider')).toBeNull()
})
it('identifies only foreground processes that can wrap agent entrypoints', () => {
expect(isAgentForegroundWrapperProcess('node.exe')).toBe(true)
expect(isAgentForegroundWrapperProcess('/usr/bin/python3')).toBe(true)
expect(isAgentForegroundWrapperProcess('python3.12.exe')).toBe(true)
expect(isAgentForegroundWrapperProcess('bash')).toBe(false)
expect(isAgentForegroundWrapperProcess('vim.exe')).toBe(false)
})
it('recognizes versioned Grok process names observed from the installed CLI', () => {
expect(recognizeAgentProcess('grok-0.2.51')).toEqual({
agent: 'grok',
processName: 'grok-0.2.51'
})
})
})