Files
orca/src/cli/search-command-arguments.test.ts
T
Jinwoo Hong 3e5eb0329a feat(cli): orca search over the agent session index (#20514)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.
2026-09-16 12:03:38 -04:00

245 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
normalizeCommandPositionals,
parseArgs,
specPaths,
validateCommandAndFlags,
type ParsedArgs
} from './args'
import { parseSearchCommand } from './search-command-arguments'
import { COMMAND_SPECS } from './specs'
const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
/** The real registry pipeline, so these assertions cover the shipped spec too. */
function parseCli(argv: string[]): ParsedArgs {
const parsed = normalizeCommandPositionals(
COMMAND_SPECS,
parseArgs(argv, COMMAND_PATHS, COMMAND_SPECS)
)
validateCommandAndFlags(COMMAND_SPECS, parsed)
return parsed
}
function parseSearch(argv: string[]): ReturnType<typeof parseSearchCommand> {
return parseSearchCommand(parseCli(argv).flags)
}
function request(argv: string[]) {
const command = parseSearch(argv)
if (command.kind !== 'search') {
throw new Error(`Expected a search, got ${command.kind}`)
}
return command.request
}
describe('orca search argument parsing', () => {
it('takes the query positionally', () => {
expect(parseCli(['search', 'resize race']).commandPath).toEqual(['search'])
expect(request(['search', 'resize race'])).toEqual({ query: 'resize race' })
})
it('takes the query as --query', () => {
expect(request(['search', '--query', 'resize race'])).toEqual({ query: 'resize race' })
})
it('refuses the query given both ways', () => {
expect(() => parseCli(['search', 'one', '--query', 'two'])).toThrow(
'Pass --query either positionally or as a flag, not both.'
)
})
it('refuses a search with no query', () => {
expect(() => parseSearch(['search'])).toThrow('Missing a search query')
})
it('maps every flag onto the contract request', () => {
expect(
request([
'search',
'kernel panic',
'--scope',
'conversation',
'--fresh',
'--limit',
'50',
'--cursor',
'eyJ2IjoxfQ',
'--agent',
'claude',
'--agent',
'codex',
'--path',
'/Users/me/orca',
'--path',
'C:\\src\\orca',
'--since',
'2026-08-01T00:00:00Z',
'--sort',
'newest',
'--debug'
])
).toEqual({
query: 'kernel panic',
scope: 'conversation',
freshness: 'wait-until-current',
limit: 50,
cursor: 'eyJ2IjoxfQ',
filters: {
agents: ['claude', 'codex'],
scopePaths: ['/Users/me/orca', 'C:\\src\\orca'],
since: '2026-08-01T00:00:00Z',
sort: 'newest'
},
debug: true
})
})
it('omits every optional field the caller did not name', () => {
expect(Object.keys(request(['search', 'q']))).toEqual(['query'])
})
it('reads --scope all and --sort relevance', () => {
expect(request(['search', 'q', '--scope', 'all', '--sort', 'relevance'])).toMatchObject({
scope: 'all',
filters: { sort: 'relevance' }
})
})
it('accepts --flag=value for a repeated flag', () => {
expect(request(['search', 'q', '--path=/a', '--path=/b'])).toMatchObject({
filters: { scopePaths: ['/a', '/b'] }
})
})
it('rejects an unsupported --scope', () => {
expect(() => parseSearch(['search', 'q', '--scope', 'files'])).toThrow(
'Unsupported --scope "files". Use conversation or all.'
)
})
it('rejects an unsupported --sort', () => {
expect(() => parseSearch(['search', 'q', '--sort', 'oldest'])).toThrow(
'Unsupported --sort "oldest". Use relevance or newest.'
)
})
it('rejects an unknown --agent and names the known ones', () => {
expect(() => parseSearch(['search', 'q', '--agent', 'claude', '--agent', 'bogus'])).toThrow(
/Unknown --agent "bogus"\. Known agents: claude, codex, /
)
})
it('rejects more --path values than the contract accepts', () => {
const paths = Array.from({ length: 65 }, (_, index) => ['--path', `/p${index}`]).flat()
expect(() => parseSearch(['search', 'q', ...paths])).toThrow('Too many --path values (65)')
})
it('accepts the maximum number of --path values', () => {
const paths = Array.from({ length: 64 }, (_, index) => ['--path', `/p${index}`]).flat()
expect(request(['search', 'q', ...paths]).filters?.scopePaths).toHaveLength(64)
})
it('rejects a --since without an offset', () => {
expect(() => parseSearch(['search', 'q', '--since', '2026-08-01'])).toThrow(
'Invalid --since "2026-08-01"'
)
})
it.each([
['--limit', '0'],
['--limit', '-1'],
['--limit', '1.5'],
['--limit', 'many']
])('rejects %s %s', (flag, value) => {
expect(() => parseSearch(['search', 'q', flag, value])).toThrow(/--limit/)
})
it('rejects a valueless --cursor', () => {
expect(() => parseSearch(['search', 'q', '--cursor', '--json'])).toThrow(
'--cursor requires a value; it was passed with none.'
)
})
it('rejects an unknown flag against the live registry', () => {
expect(() => parseCli(['search', 'q', '--tier', 'fast'])).toThrow(
'Unknown flag --tier for command: search'
)
})
it('does not accept the browser --page flag', () => {
expect(() => parseCli(['search', 'q', '--page', 'page_1'])).toThrow(
'Unknown flag --page for command: search'
)
})
})
describe('orca search --index-status', () => {
it('asks for the index report', () => {
expect(parseSearch(['search', '--index-status'])).toEqual({ kind: 'index-status' })
})
it.each([
[['search', 'q', '--index-status'], '--query'],
[['search', '--index-status', '--limit', '5'], '--limit'],
[['search', '--index-status', '--fresh'], '--fresh'],
[['search', '--index-status', '--agent', 'claude'], '--agent']
])('refuses %j because it also names %s', (argv, flag) => {
expect(() => parseSearch(argv)).toThrow(
`--index-status reports on the index and takes no query, so it cannot be combined with ${flag}.`
)
})
})
describe('repeatable flags are command-scoped', () => {
it('repeats --agent for search', () => {
expect(request(['search', 'q', '--agent', 'claude', '--agent', 'codex'])).toMatchObject({
filters: { agents: ['claude', 'codex'] }
})
})
it('repeats --agent placed before the command', () => {
expect(request(['--agent', 'claude', '--agent', 'codex', 'search', 'q'])).toMatchObject({
filters: { agents: ['claude', 'codex'] }
})
})
it('repeats --path across the command boundary', () => {
expect(request(['--path', '/a', 'search', 'q', '--path', '/b'])).toMatchObject({
filters: { scopePaths: ['/a', '/b'] }
})
})
it('leaves a pre-command --agent single-valued for worktree create', () => {
const parsed = parseCli([
'--agent',
'claude',
'--agent',
'codex',
'worktree',
'create',
'--name',
'w'
])
expect(parsed.flags.get('agent')).toBe('codex')
})
it('leaves --agent single-valued for worktree create', () => {
const parsed = parseCli([
'worktree',
'create',
'--name',
'w',
'--agent',
'claude',
'--agent',
'codex'
])
expect(parsed.flags.get('agent')).toBe('codex')
})
it('leaves --path single-valued for repo add', () => {
expect(parseCli(['repo', 'add', '--path', '/a', '--path', '/b']).flags.get('path')).toBe('/b')
})
})