mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
Merge remote-tracking branch 'origin/main' into session-search-remote-enable-2
This commit is contained in:
@@ -57,6 +57,21 @@ describe('buildAgentContext', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('omits hidden specs so agents do not discover an unadvertised command', () => {
|
||||
const schema = buildAgentContext([
|
||||
...specs,
|
||||
{
|
||||
path: ['terminal', 'stop'],
|
||||
summary: 'Deprecated',
|
||||
usage: 'orca terminal stop',
|
||||
allowedFlags: [],
|
||||
hidden: true
|
||||
}
|
||||
])
|
||||
expect(schema.commandCount).toBe(2)
|
||||
expect(schema.commands.map((command) => command.command)).not.toContain('terminal stop')
|
||||
})
|
||||
|
||||
it('defaults optional fields to empty arrays', () => {
|
||||
const schema = buildAgentContext(specs)
|
||||
const agentContext = schema.commands.find((command) => command.command === 'agent-context')
|
||||
|
||||
@@ -27,6 +27,8 @@ export type AgentContextSchema = {
|
||||
|
||||
export function buildAgentContext(specs: CommandSpec[]): AgentContextSchema {
|
||||
const commands = specs
|
||||
// Why: hidden specs dispatch but stay off every discovery surface, including this one.
|
||||
.filter((spec) => spec.hidden !== true)
|
||||
.map((spec) => ({
|
||||
command: spec.path.join(' '),
|
||||
path: spec.path,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatSessionSearchResponse,
|
||||
formatSessionSearchStatus,
|
||||
terminalSafe
|
||||
} from './agent-session-search-format'
|
||||
import type {
|
||||
AiVaultSearchHit,
|
||||
AiVaultSearchResponse,
|
||||
AiVaultSearchStatus
|
||||
} from '../shared/ai-vault-search-types'
|
||||
|
||||
const localHit: AiVaultSearchHit = {
|
||||
agent: 'claude',
|
||||
executionHostId: 'ssh:build-01',
|
||||
sessionId: 'session-1',
|
||||
title: 'Terminal resize race on Windows',
|
||||
cwd: 'C:\\src\\orca',
|
||||
branch: 'main',
|
||||
updatedAt: '2026-09-12T18:04:11.000Z',
|
||||
messageCount: 214,
|
||||
score: 12.5,
|
||||
source: { presence: 'present', filePath: '/transcripts/session-1.jsonl' },
|
||||
evidence: {
|
||||
snippet: 'the [[resize]] handler drops the first event',
|
||||
role: 'assistant',
|
||||
timestamp: '2026-09-12T18:04:11.000Z'
|
||||
},
|
||||
resumeCommand: 'claude --resume session-1'
|
||||
}
|
||||
|
||||
const evidencelessHit: AiVaultSearchHit = {
|
||||
agent: 'codex',
|
||||
sessionId: 'session-2',
|
||||
title: 'Index sweep budget',
|
||||
cwd: null,
|
||||
branch: null,
|
||||
updatedAt: null,
|
||||
messageCount: 4,
|
||||
score: 3,
|
||||
source: { presence: 'unverifiable' },
|
||||
evidence: null
|
||||
}
|
||||
|
||||
function results(overrides: Partial<Extract<AiVaultSearchResponse, { kind: 'results' }>> = {}) {
|
||||
return {
|
||||
kind: 'results' as const,
|
||||
hits: [localHit],
|
||||
page: { cursor: null, hasMore: false },
|
||||
generation: 42,
|
||||
truncated: { candidates: false, snippets: 0, query: false, freshness: false },
|
||||
durationMs: 18,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatSessionSearchResponse: results', () => {
|
||||
it('prints one line per hit with the snippet indented under it', () => {
|
||||
expect(formatSessionSearchResponse(results())).toBe(
|
||||
[
|
||||
'Claude 2026-09-12T18:04:11.000Z Terminal resize race on Windows host=ssh:build-01',
|
||||
' assistant: the [[resize]] handler drops the first event',
|
||||
' resume: claude --resume session-1',
|
||||
'',
|
||||
'1 result on this page, 18 ms.'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the [[ ]] match marks exactly as the engine wrote them', () => {
|
||||
expect(formatSessionSearchResponse(results())).toContain('[[resize]]')
|
||||
})
|
||||
|
||||
it('omits the host and the resume line a paired host withheld', () => {
|
||||
const { executionHostId: _host, resumeCommand: _resume, ...withheld } = localHit
|
||||
expect(formatSessionSearchResponse(results({ hits: [withheld] }))).toBe(
|
||||
[
|
||||
'Claude 2026-09-12T18:04:11.000Z Terminal resize race on Windows',
|
||||
' assistant: the [[resize]] handler drops the first event',
|
||||
'',
|
||||
'1 result on this page, 18 ms.'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('says so when a hit matched with no text evidence', () => {
|
||||
expect(formatSessionSearchResponse(results({ hits: [evidencelessHit] }))).toContain(
|
||||
'Codex unknown time Index sweep budget\n no text evidence for this match'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports zero hits as an answer, not a failure', () => {
|
||||
expect(formatSessionSearchResponse(results({ hits: [] }))).toBe(
|
||||
['No sessions match this query.', '', '0 results on this page, 18 ms.'].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('prints the cursor a caller passes back for the next page', () => {
|
||||
expect(
|
||||
formatSessionSearchResponse(results({ page: { cursor: 'eyJ2IjoxfQ', hasMore: true } }))
|
||||
).toContain('more pages: re-run with --cursor eyJ2IjoxfQ')
|
||||
})
|
||||
|
||||
it('admits more pages exist when the host issued no cursor', () => {
|
||||
expect(
|
||||
formatSessionSearchResponse(results({ page: { cursor: null, hasMore: true } }))
|
||||
).toContain('more pages exist, but this host issued no cursor for them')
|
||||
})
|
||||
|
||||
it('renders every truncation flag in words', () => {
|
||||
const text = formatSessionSearchResponse(
|
||||
results({ truncated: { candidates: true, snippets: 3, query: true, freshness: true } })
|
||||
)
|
||||
expect(text).toContain(
|
||||
'ranking saw only the first batch of candidate sessions, so a better match may be missing'
|
||||
)
|
||||
expect(text).toContain('query was cut')
|
||||
expect(text).toContain(
|
||||
'freshness wait timed out; these results come from the index as it stood'
|
||||
)
|
||||
expect(text).toContain('3 snippets were shortened')
|
||||
})
|
||||
|
||||
it('prints nothing about truncation when nothing was truncated', () => {
|
||||
expect(formatSessionSearchResponse(results())).not.toContain('truncat')
|
||||
})
|
||||
|
||||
it('prints the planner route only when the host sent debug', () => {
|
||||
expect(formatSessionSearchResponse(results())).not.toContain('debug:')
|
||||
expect(
|
||||
formatSessionSearchResponse(
|
||||
results({
|
||||
debug: {
|
||||
route: 'typo+phrase',
|
||||
repairedTerms: ['resize'],
|
||||
plannerReport: { route: 'typo+phrase', repairedTerms: ['resize'], scope: 'all' }
|
||||
}
|
||||
})
|
||||
)
|
||||
).toContain(
|
||||
['debug:', ' route: typo+phrase', ' repairedTerms: resize', ' plannerScope: all'].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSessionSearchResponse: non-result answers', () => {
|
||||
it('reports a disabled index', () => {
|
||||
expect(formatSessionSearchResponse({ kind: 'unavailable', reason: 'disabled' })).toBe(
|
||||
'Session search is off on this host.'
|
||||
)
|
||||
})
|
||||
|
||||
it('names an index that has not started', () => {
|
||||
expect(formatSessionSearchResponse({ kind: 'unavailable', reason: 'not-ready' })).toContain(
|
||||
'not ready on this host yet'
|
||||
)
|
||||
})
|
||||
|
||||
it('tells an old host apart as a host with no service', () => {
|
||||
expect(formatSessionSearchResponse({ kind: 'unavailable', reason: 'no-service' })).toBe(
|
||||
[
|
||||
'This host runs no session search service.',
|
||||
'An Orca host older than session search answers the same way; update it and try again.'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('says the index moved and to drop the cursor', () => {
|
||||
expect(formatSessionSearchResponse({ kind: 'stale-cursor', generation: 7 })).toBe(
|
||||
[
|
||||
'The index moved on since that page, so the cursor no longer names a place in it.',
|
||||
'Re-run the same search without --cursor to start again from page 1.'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal safety', () => {
|
||||
it('strips escape sequences a transcript could carry into the reader terminal', () => {
|
||||
expect(terminalSafe('before\u001b]52;c;cGF5bG9hZA==\u0007after')).toBe('beforeafter')
|
||||
expect(terminalSafe('red \u001b[31mtext\u001b[0m')).toBe('red text')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['updatedAt', { updatedAt: '2026-09-12\u001b[31mT18:04:11.000Z' }, '\u001b'],
|
||||
['executionHostId', { executionHostId: 'ssh:\u001b]0;pwned\u0007build-01' }, '\u001b'],
|
||||
['title', { title: 'resize\u001b[2Jrace' }, '\u001b'],
|
||||
['evidence snippet', { evidence: { ...localHit.evidence!, snippet: 'a\u001b[1mb' } }, '\u001b'],
|
||||
['resumeCommand', { resumeCommand: 'claude \u001b[3Jresume' }, '\u001b']
|
||||
])('strips an escape sequence a host put in %s', (_field, override, escape) => {
|
||||
const text = formatSessionSearchResponse(results({ hits: [{ ...localHit, ...override }] }))
|
||||
|
||||
expect(text).not.toContain(escape)
|
||||
})
|
||||
|
||||
it('strips an escape sequence a host put in the next-page cursor', () => {
|
||||
const text = formatSessionSearchResponse(
|
||||
results({ page: { cursor: 'eyJ2\u001b[31mIjoxfQ', hasMore: true } })
|
||||
)
|
||||
|
||||
expect(text).toContain('more pages: re-run with --cursor eyJ2IjoxfQ')
|
||||
expect(text).not.toContain('\u001b')
|
||||
})
|
||||
|
||||
it('strips an escape sequence a host put in a repaired term', () => {
|
||||
const text = formatSessionSearchResponse(
|
||||
results({
|
||||
debug: {
|
||||
route: 'typo+phrase',
|
||||
repairedTerms: ['resize\u001b[31m', '\u001b]0;t\u0007race'],
|
||||
plannerReport: { route: 'typo+phrase', scope: 'all' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(text).toContain(' repairedTerms: resize race')
|
||||
expect(text).not.toContain('\u001b')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['root', { root: '/Users/me/\u001b[31m.codex', reason: 'EACCES' }],
|
||||
['reason', { root: '/r', reason: 'EACCES\u001b]0;x\u0007' }]
|
||||
])('strips an escape sequence a host put in a degraded %s', (_field, degradedRoot) => {
|
||||
const text = formatSessionSearchStatus({
|
||||
enabled: true,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 1,
|
||||
filesDue: 0,
|
||||
filesFailed: 1,
|
||||
degradedRoots: [degradedRoot],
|
||||
lastReconcileAt: null,
|
||||
lastSweepCompletedAt: null,
|
||||
generation: 1
|
||||
})
|
||||
|
||||
expect(text).not.toContain('\u001b')
|
||||
})
|
||||
|
||||
it('keeps a snippet on the one indented line it was given', () => {
|
||||
const text = formatSessionSearchResponse(
|
||||
results({
|
||||
hits: [
|
||||
{
|
||||
...localHit,
|
||||
title: 'wrapped\ntitle',
|
||||
evidence: { ...localHit.evidence!, snippet: 'first\nsecond\u0007' }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(text).toContain('Claude 2026-09-12T18:04:11.000Z wrapped title host=ssh:build-01')
|
||||
expect(text).toContain(' assistant: first second')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSessionSearchStatus', () => {
|
||||
const status: AiVaultSearchStatus = {
|
||||
enabled: true,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 5535,
|
||||
filesDue: 12,
|
||||
filesFailed: 1,
|
||||
degradedRoots: [{ root: '/Users/me/.codex', reason: 'EACCES' }],
|
||||
lastReconcileAt: 1789000000000,
|
||||
lastSweepCompletedAt: null,
|
||||
generation: 42
|
||||
}
|
||||
|
||||
it('reports the indexer observations one per line', () => {
|
||||
expect(formatSessionSearchStatus(status)).toBe(
|
||||
[
|
||||
'enabled: true',
|
||||
'phase: indexing',
|
||||
'filesIndexed: 5535',
|
||||
'filesDue: 12',
|
||||
'filesFailed: 1',
|
||||
'lastReconcileAt: 2026-09-10T00:26:40.000Z',
|
||||
'lastSweepCompletedAt: never',
|
||||
'generation: 42',
|
||||
'degradedRoots: 1',
|
||||
' /Users/me/.codex: EACCES'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the count when a paired host withheld the root', () => {
|
||||
expect(
|
||||
formatSessionSearchStatus({
|
||||
...status,
|
||||
degradedRoots: [{ reason: 'Source root could not be verified.' }]
|
||||
})
|
||||
).toContain('degradedRoots: 1\n (withheld): Source root could not be verified.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
stripAnsiEscapeSequences,
|
||||
TERMINAL_CONTROL_CHARACTER_PATTERN
|
||||
} from '../shared/ansi-escape-sequences'
|
||||
import { aiVaultAgentLabel } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSearchHit,
|
||||
AiVaultSearchResponse,
|
||||
AiVaultSearchStatus
|
||||
} from '../shared/ai-vault-search-types'
|
||||
|
||||
type SessionSearchResults = Extract<AiVaultSearchResponse, { kind: 'results' }>
|
||||
/** Malformed cursors are a caller mistake and leave through the CLI error channel. */
|
||||
export type PrintableSessionSearchResponse = Exclude<
|
||||
AiVaultSearchResponse,
|
||||
{ kind: 'malformed-cursor' }
|
||||
>
|
||||
|
||||
/**
|
||||
* Transcript text reaches the terminal verbatim, so an OSC 52 or cursor sequence
|
||||
* inside a tool log would otherwise run on the reader's terminal. The `[[` `]]`
|
||||
* match marks are left as the engine wrote them: this CLI emits no ANSI anywhere,
|
||||
* so a colour scheme invented here would be the only one in the surface.
|
||||
*/
|
||||
export function terminalSafe(value: string): string {
|
||||
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
|
||||
}
|
||||
|
||||
function oneLine(value: string): string {
|
||||
return terminalSafe(value)
|
||||
.replaceAll(/[\r\n]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatHit(hit: AiVaultSearchHit): string {
|
||||
const host = oneLine(hit.executionHostId ?? '')
|
||||
const header = [
|
||||
aiVaultAgentLabel(hit.agent),
|
||||
oneLine(hit.updatedAt ?? '') || 'unknown time',
|
||||
oneLine(hit.title) || '(untitled)',
|
||||
...(host ? [`host=${host}`] : [])
|
||||
].join(' ')
|
||||
const evidence = hit.evidence
|
||||
? ` ${hit.evidence.role}: ${oneLine(hit.evidence.snippet)}`
|
||||
: ' no text evidence for this match'
|
||||
// Withheld for paired callers by the contract, so its absence is not a failure.
|
||||
const resume = hit.resumeCommand ? [` resume: ${oneLine(hit.resumeCommand)}`] : []
|
||||
return [header, evidence, ...resume].join('\n')
|
||||
}
|
||||
|
||||
function formatTruncation(truncated: SessionSearchResults['truncated']): string[] {
|
||||
return [
|
||||
...(truncated.candidates
|
||||
? ['ranking saw only the first batch of candidate sessions, so a better match may be missing']
|
||||
: []),
|
||||
...(truncated.query ? ['query was cut'] : []),
|
||||
...(truncated.freshness
|
||||
? ['freshness wait timed out; these results come from the index as it stood']
|
||||
: []),
|
||||
...(truncated.snippets > 0 ? [`${truncated.snippets} snippets were shortened`] : [])
|
||||
]
|
||||
}
|
||||
|
||||
function formatDebug(debug: SessionSearchResults['debug']): string[] {
|
||||
if (!debug) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
'',
|
||||
'debug:',
|
||||
` route: ${debug.route}`,
|
||||
...(debug.repairedTerms
|
||||
? [` repairedTerms: ${debug.repairedTerms.map((term) => oneLine(term)).join(' ')}`]
|
||||
: []),
|
||||
` plannerScope: ${debug.plannerReport.scope}`
|
||||
]
|
||||
}
|
||||
|
||||
function formatUnavailable(reason: 'disabled' | 'not-ready' | 'no-service'): string {
|
||||
if (reason === 'disabled') {
|
||||
return 'Session search is off on this host.'
|
||||
}
|
||||
if (reason === 'not-ready') {
|
||||
return 'Session search is not ready on this host yet. Try again once its index has started.'
|
||||
}
|
||||
return [
|
||||
'This host runs no session search service.',
|
||||
'An Orca host older than session search answers the same way; update it and try again.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatResults(response: SessionSearchResults): string {
|
||||
const body =
|
||||
response.hits.length === 0 ? ['No sessions match this query.'] : response.hits.map(formatHit)
|
||||
const cursor = oneLine(response.page.cursor ?? '')
|
||||
const footer = [
|
||||
`${response.hits.length} ${response.hits.length === 1 ? 'result' : 'results'} on this page, ${Math.round(response.durationMs)} ms.`,
|
||||
...(response.page.hasMore && cursor
|
||||
? [`more pages: re-run with --cursor ${cursor}`]
|
||||
: response.page.hasMore
|
||||
? ['more pages exist, but this host issued no cursor for them']
|
||||
: []),
|
||||
...formatTruncation(response.truncated)
|
||||
]
|
||||
return [...body, '', ...footer, ...formatDebug(response.debug)].join('\n')
|
||||
}
|
||||
|
||||
export function formatSessionSearchResponse(response: PrintableSessionSearchResponse): string {
|
||||
if (response.kind === 'unavailable') {
|
||||
return formatUnavailable(response.reason)
|
||||
}
|
||||
if (response.kind === 'stale-cursor') {
|
||||
return [
|
||||
'The index moved on since that page, so the cursor no longer names a place in it.',
|
||||
'Re-run the same search without --cursor to start again from page 1.'
|
||||
].join('\n')
|
||||
}
|
||||
return formatResults(response)
|
||||
}
|
||||
|
||||
function formatEpochMs(value: number | null): string {
|
||||
return value === null ? 'never' : new Date(value).toISOString()
|
||||
}
|
||||
|
||||
export function formatSessionSearchStatus(status: AiVaultSearchStatus): string {
|
||||
return [
|
||||
`enabled: ${status.enabled}`,
|
||||
`phase: ${status.phase}`,
|
||||
`filesIndexed: ${status.filesIndexed}`,
|
||||
`filesDue: ${status.filesDue}`,
|
||||
`filesFailed: ${status.filesFailed}`,
|
||||
`lastReconcileAt: ${formatEpochMs(status.lastReconcileAt)}`,
|
||||
`lastSweepCompletedAt: ${formatEpochMs(status.lastSweepCompletedAt)}`,
|
||||
`generation: ${status.generation}`,
|
||||
`degradedRoots: ${status.degradedRoots.length}`,
|
||||
// A paired host withholds the root itself and sends the count with a fixed reason.
|
||||
...status.degradedRoots.map(
|
||||
(root) => ` ${root.root ? oneLine(root.root) : '(withheld)'}: ${oneLine(root.reason)}`
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseArgs, REPEATED_FLAG_SEPARATOR, specPaths, type CommandSpec } from './args'
|
||||
|
||||
const leaf: CommandSpec = {
|
||||
path: ['example', 'search'],
|
||||
aliases: [['ex', 'find']],
|
||||
summary: '',
|
||||
usage: '',
|
||||
allowedFlags: ['agent'],
|
||||
repeatableFlags: ['agent']
|
||||
}
|
||||
const parent: CommandSpec = {
|
||||
path: ['example'],
|
||||
summary: '',
|
||||
usage: '',
|
||||
allowedFlags: ['path'],
|
||||
repeatableFlags: ['path']
|
||||
}
|
||||
const specs = [parent, leaf]
|
||||
const paths = specs.flatMap(specPaths)
|
||||
|
||||
function flags(argv: string[]) {
|
||||
return parseArgs(argv, paths, specs).flags
|
||||
}
|
||||
|
||||
describe('command-scoped repeatable flags', () => {
|
||||
it.each(specPaths(leaf))('keeps values before and between %s %s command words', (...path) => {
|
||||
const parsed = flags(['--agent', 'a', path[0], '--agent=b', path[1], '--agent', 'c'])
|
||||
expect(parsed.get('agent')).toBe(['a', 'b', 'c'].join(REPEATED_FLAG_SEPARATOR))
|
||||
})
|
||||
|
||||
it('uses the leaf rules for all flags even when the parent has different rules', () => {
|
||||
const parsed = flags(['--path', '/a', 'example', '--path', '/b', 'search', '--path', '/c'])
|
||||
expect(parsed.get('path')).toBe('/c')
|
||||
})
|
||||
|
||||
it('still uses the parent rules when the parent itself is invoked', () => {
|
||||
expect(flags(['--path', '/a', 'example', '--path=/b']).get('path')).toBe(
|
||||
['/a', '/b'].join(REPEATED_FLAG_SEPARATOR)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a trailing valueless flag for downstream validation', () => {
|
||||
expect(flags(['example', 'search', '--agent=a', '--agent']).get('agent')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the existing reset behavior when a valueless flag precedes more values', () => {
|
||||
expect(
|
||||
flags(['example', 'search', '--agent=a', '--agent', '--agent=b', '--agent=c']).get('agent')
|
||||
).toBe(['b', 'c'].join(REPEATED_FLAG_SEPARATOR))
|
||||
})
|
||||
|
||||
it('preserves empty values and equals signs without reinterpreting flag-like values', () => {
|
||||
expect(
|
||||
flags(['example', 'search', '--agent=', '--agent=--help', '--agent=a=b']).get('agent')
|
||||
).toBe(['', '--help', 'a=b'].join(REPEATED_FLAG_SEPARATOR))
|
||||
})
|
||||
})
|
||||
+51
-9
@@ -24,18 +24,47 @@ export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS
|
||||
export const REPEATED_FLAG_SEPARATOR = '\u0000'
|
||||
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
|
||||
|
||||
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
|
||||
function setFlagValue(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
value: string,
|
||||
repeatable: ReadonlySet<string>
|
||||
): void {
|
||||
const existing = flags.get(name)
|
||||
if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
|
||||
if (typeof existing === 'string' && repeatable.has(name)) {
|
||||
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
|
||||
return
|
||||
}
|
||||
flags.set(name, value)
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[], commandPaths?: readonly string[][]): ParsedArgs {
|
||||
/** The most specific spec whose path prefixes `path`, so a group never shadows a leaf. */
|
||||
function specForPathPrefix(
|
||||
specs: readonly CommandSpec[],
|
||||
path: readonly string[]
|
||||
): CommandSpec | undefined {
|
||||
let best: { spec: CommandSpec; length: number } | undefined
|
||||
for (const spec of specs) {
|
||||
for (const candidate of specPaths(spec)) {
|
||||
if (
|
||||
candidate.length <= path.length &&
|
||||
candidate.every((part, index) => part === path[index]) &&
|
||||
(!best || candidate.length > best.length)
|
||||
) {
|
||||
best = { spec, length: candidate.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best?.spec
|
||||
}
|
||||
|
||||
export function parseArgs(
|
||||
argv: string[],
|
||||
commandPaths?: readonly string[][],
|
||||
specs: readonly CommandSpec[] = []
|
||||
): ParsedArgs {
|
||||
const commandPath: string[] = []
|
||||
const flags = new Map<string, string | boolean>()
|
||||
const flagEntries: [string, string | boolean][] = []
|
||||
const commandIndex = findCliCommandIndex(argv, commandPaths ?? [])
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
@@ -51,30 +80,42 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P
|
||||
// treats a `--`-leading next token as a new flag, so it can't express one.
|
||||
const equalsIndex = assignment.indexOf('=')
|
||||
if (equalsIndex !== -1) {
|
||||
setFlagValue(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
|
||||
flagEntries.push([assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)])
|
||||
continue
|
||||
}
|
||||
|
||||
const flag = assignment
|
||||
if (BOOLEAN_FLAGS.has(flag)) {
|
||||
flags.set(flag, true)
|
||||
flagEntries.push([flag, true])
|
||||
continue
|
||||
}
|
||||
// Why: a pre-command flag must not consume a registry-resolvable command path.
|
||||
if (commandPath.length === 0 && i + 1 === commandIndex) {
|
||||
flags.set(flag, true)
|
||||
flagEntries.push([flag, true])
|
||||
continue
|
||||
}
|
||||
const hasNext = i + 1 < argv.length
|
||||
const next = argv[i + 1]
|
||||
if (!hasNext || next.startsWith('--')) {
|
||||
flags.set(flag, true)
|
||||
flagEntries.push([flag, true])
|
||||
continue
|
||||
}
|
||||
setFlagValue(flags, flag, next)
|
||||
flagEntries.push([flag, next])
|
||||
i += 1
|
||||
}
|
||||
|
||||
const declared = specForPathPrefix(specs, commandPath)?.repeatableFlags
|
||||
const repeatable = declared
|
||||
? new Set([...REPEATABLE_STRING_FLAGS, ...declared])
|
||||
: REPEATABLE_STRING_FLAGS
|
||||
const flags = new Map<string, string | boolean>()
|
||||
for (const [name, value] of flagEntries) {
|
||||
if (typeof value === 'string') {
|
||||
setFlagValue(flags, name, value, repeatable)
|
||||
} else {
|
||||
flags.set(name, value)
|
||||
}
|
||||
}
|
||||
return { commandPath, flags }
|
||||
}
|
||||
|
||||
@@ -116,6 +157,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
'diagnostics',
|
||||
'linear',
|
||||
'skills',
|
||||
'search',
|
||||
'agent-context'
|
||||
].includes(commandPath[0])
|
||||
) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
||||
/** Per-command flag help, kept out of the shared help chain it would crowd. */
|
||||
const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
|
||||
'skills get': {
|
||||
full: '--full Print the full guide with bundled references',
|
||||
reference: '--reference <name> Print one bundled reference by name',
|
||||
references: '--references List the bundled reference names for a topic'
|
||||
},
|
||||
'skills install': {
|
||||
agent: '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
},
|
||||
search: {
|
||||
query: '--query <text> Search text; also accepted as the positional argument',
|
||||
scope: '--scope <corpus> conversation (user and assistant turns) or all (default)',
|
||||
fresh: '--fresh Wait up to 5s for the host to reconcile its index first',
|
||||
limit: '--limit <n> Hits per page (default 20, maximum 100)',
|
||||
cursor: '--cursor <cursor> Opaque cursor printed by the previous page of this search',
|
||||
agent: '--agent <id> Restrict to one agent; repeat for several',
|
||||
path: '--path <path> Restrict to an execution-host path; repeat for several',
|
||||
since: '--since <iso> Only sessions updated at or after this ISO 8601 timestamp',
|
||||
sort: '--sort <order> relevance (default) or newest',
|
||||
debug: '--debug Include the planner route the host used',
|
||||
'index-status': '--index-status Report the index instead of searching'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCommandScopedFlagHelp(command: string, flag: string): string | undefined {
|
||||
return COMMAND_SCOPED_FLAG_HELP[command]?.[flag]
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export type CommandSpec = {
|
||||
summary: string
|
||||
usage: string
|
||||
allowedFlags: string[]
|
||||
// Why: repeatability is per-command vocabulary. `--agent` repeats for `search`
|
||||
// and is single-valued for `worktree create`, which one global set cannot say.
|
||||
repeatableFlags?: string[]
|
||||
positionalArgs?: string[]
|
||||
examples?: string[]
|
||||
notes?: string[]
|
||||
|
||||
@@ -251,5 +251,10 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
name: 'skills',
|
||||
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
|
||||
load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
keys: ['search'],
|
||||
load: async () => (await import('./handlers/search.js')).SEARCH_HANDLERS
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MALFORMED_CURSOR_MESSAGE, SEARCH_HANDLERS } from './search'
|
||||
import { AiVaultSearchResponseSchema } from '../../shared/ai-vault-search-contract'
|
||||
import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
|
||||
import { REPEATED_FLAG_SEPARATOR } from '../args'
|
||||
import { RuntimeClientError } from '../runtime/types'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const hit = {
|
||||
agent: 'claude' as const,
|
||||
executionHostId: 'ssh:build-01',
|
||||
sessionId: 'session-1',
|
||||
title: 'Terminal resize race',
|
||||
cwd: '/src/orca',
|
||||
branch: 'main',
|
||||
updatedAt: '2026-09-12T18:04:11.000Z',
|
||||
messageCount: 214,
|
||||
score: 12.5,
|
||||
evidence: {
|
||||
snippet: 'the [[resize]] handler drops the first event',
|
||||
role: 'assistant' as const,
|
||||
timestamp: '2026-09-12T18:04:11.000Z'
|
||||
},
|
||||
source: { presence: 'present' as const, filePath: '/transcripts/session-1.jsonl' },
|
||||
resumeCommand: 'claude --resume session-1'
|
||||
}
|
||||
|
||||
const resultsResponse: AiVaultSearchResponse = {
|
||||
kind: 'results',
|
||||
hits: [hit],
|
||||
page: { cursor: 'eyJ2IjoxfQ', hasMore: true },
|
||||
generation: 42,
|
||||
truncated: { candidates: false, snippets: 0, query: false, freshness: false },
|
||||
durationMs: 18
|
||||
}
|
||||
|
||||
const statusResponse: AiVaultSearchStatus = {
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
filesDue: 0,
|
||||
filesFailed: 0,
|
||||
degradedRoots: [],
|
||||
lastReconcileAt: 1789000000000,
|
||||
lastSweepCompletedAt: 1789000000000,
|
||||
generation: 42
|
||||
}
|
||||
|
||||
function envelope(result: unknown) {
|
||||
return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
async function runSearch(
|
||||
flags: [string, string | boolean][],
|
||||
options: {
|
||||
result?: unknown
|
||||
error?: unknown
|
||||
json?: boolean
|
||||
isRemote?: boolean
|
||||
} = {}
|
||||
): Promise<{ call: ReturnType<typeof vi.fn>; output: string }> {
|
||||
const call = options.error
|
||||
? vi.fn().mockRejectedValue(options.error)
|
||||
: vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
|
||||
const lines: string[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
|
||||
lines.push(String(value))
|
||||
})
|
||||
await SEARCH_HANDLERS.search!({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only `call` and `isRemote`; a real RuntimeClient would resolve runtime metadata and open a socket.
|
||||
client: { call, isRemote: options.isRemote ?? false } as never,
|
||||
cwd: '/workspace',
|
||||
flags: new Map(flags),
|
||||
json: options.json ?? false
|
||||
})
|
||||
return { call, output: lines.join('\n') }
|
||||
}
|
||||
|
||||
type CliFlags = [string, string | boolean][]
|
||||
|
||||
/** The printed envelope, narrowed by shape rather than asserted. */
|
||||
function printedEnvelope(output: string): { keys: string[]; result: unknown } {
|
||||
const parsed: unknown = JSON.parse(output)
|
||||
if (typeof parsed !== 'object' || parsed === null || !('result' in parsed)) {
|
||||
throw new Error(`Not an RPC envelope: ${output}`)
|
||||
}
|
||||
return { keys: Object.keys(parsed), result: parsed.result }
|
||||
}
|
||||
|
||||
/** Re-reads the printed result through the contract, so the shape is checked, not claimed. */
|
||||
function printedResults(output: string): Extract<AiVaultSearchResponse, { kind: 'results' }> {
|
||||
const parsed = AiVaultSearchResponseSchema.parse(printedEnvelope(output).result)
|
||||
if (parsed.kind !== 'results') {
|
||||
throw new Error(`Expected results, got ${parsed.kind}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
describe('orca search over the runtime RPC', () => {
|
||||
it('sends the query with the contract defaults the schema resolves', async () => {
|
||||
const { call } = await runSearch([['query', 'resize race']])
|
||||
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { query: 'resize race', limit: 20 })
|
||||
})
|
||||
|
||||
const flagCases: [string, CliFlags, Record<string, unknown>][] = [
|
||||
[
|
||||
'scope and freshness',
|
||||
[
|
||||
['query', 'q'],
|
||||
['scope', 'conversation'],
|
||||
['fresh', true]
|
||||
],
|
||||
{ query: 'q', scope: 'conversation', freshness: 'wait-until-current', limit: 20 }
|
||||
],
|
||||
[
|
||||
'paging',
|
||||
[
|
||||
['query', 'q'],
|
||||
['limit', '50'],
|
||||
['cursor', 'eyJ2IjoxfQ']
|
||||
],
|
||||
{ query: 'q', limit: 50, cursor: 'eyJ2IjoxfQ' }
|
||||
],
|
||||
[
|
||||
'filters',
|
||||
[
|
||||
['query', 'q'],
|
||||
['agent', `claude${REPEATED_FLAG_SEPARATOR}codex`],
|
||||
['path', `/a${REPEATED_FLAG_SEPARATOR}/b`],
|
||||
['since', '2026-08-01T00:00:00Z'],
|
||||
['sort', 'newest']
|
||||
],
|
||||
{
|
||||
query: 'q',
|
||||
limit: 20,
|
||||
filters: {
|
||||
agents: ['claude', 'codex'],
|
||||
scopePaths: ['/a', '/b'],
|
||||
since: '2026-08-01T00:00:00Z',
|
||||
sort: 'newest'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'debug',
|
||||
[
|
||||
['query', 'q'],
|
||||
['debug', true]
|
||||
],
|
||||
{ query: 'q', limit: 20, debug: true }
|
||||
]
|
||||
]
|
||||
|
||||
it.each(flagCases)('sends %s', async (_name, flags, params) => {
|
||||
const { call } = await runSearch(flags)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', params)
|
||||
})
|
||||
|
||||
it('calls the status RPC for --index-status', async () => {
|
||||
const { call, output } = await runSearch([['index-status', true]], { result: statusResponse })
|
||||
|
||||
expect(call).toHaveBeenCalledWith('aiVault.searchStatus', {})
|
||||
expect(output).toContain('phase: current')
|
||||
})
|
||||
|
||||
it('renders a result page as text', async () => {
|
||||
const { output } = await runSearch([['query', 'q']])
|
||||
|
||||
expect(output).toBe(
|
||||
[
|
||||
'Claude 2026-09-12T18:04:11.000Z Terminal resize race host=ssh:build-01',
|
||||
' assistant: the [[resize]] handler drops the first event',
|
||||
' resume: claude --resume session-1',
|
||||
'',
|
||||
'1 result on this page, 18 ms.',
|
||||
'more pages: re-run with --cursor eyJ2IjoxfQ'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a disabled index as an answer', async () => {
|
||||
const { output } = await runSearch([['query', 'q']], {
|
||||
result: { kind: 'unavailable', reason: 'disabled' }
|
||||
})
|
||||
|
||||
expect(output).toBe('Session search is off on this host.')
|
||||
})
|
||||
|
||||
it('renders a stale cursor as guidance to re-run without one', async () => {
|
||||
const { output } = await runSearch(
|
||||
[
|
||||
['query', 'q'],
|
||||
['cursor', 'eyJ2IjoxfQ']
|
||||
],
|
||||
{ result: { kind: 'stale-cursor', generation: 7, expectedGeneration: 8 } }
|
||||
)
|
||||
|
||||
expect(output).toContain('Re-run the same search without --cursor')
|
||||
})
|
||||
|
||||
it('raises a malformed cursor through the CLI error channel', async () => {
|
||||
await expect(
|
||||
runSearch(
|
||||
[
|
||||
['query', 'q'],
|
||||
['cursor', 'nope']
|
||||
],
|
||||
{ result: { kind: 'malformed-cursor' } }
|
||||
)
|
||||
).rejects.toThrow(MALFORMED_CURSOR_MESSAGE)
|
||||
})
|
||||
|
||||
it('answers a host with no such method as unavailable rather than a raw error', async () => {
|
||||
const { output } = await runSearch([['query', 'q']], {
|
||||
error: new RuntimeClientError('method_not_found', 'Unknown method aiVault.searchSessions')
|
||||
})
|
||||
|
||||
expect(output).toContain('This host runs no session search service.')
|
||||
})
|
||||
|
||||
it('answers an old host asked for status with the absent-service sentinel', async () => {
|
||||
const { output } = await runSearch([['index-status', true]], {
|
||||
error: new RuntimeClientError('method_not_found', 'Unknown method aiVault.searchStatus')
|
||||
})
|
||||
|
||||
expect(output).toContain('enabled: false')
|
||||
expect(output).toContain('phase: idle')
|
||||
})
|
||||
|
||||
it('propagates a transport failure instead of calling it unavailable', async () => {
|
||||
await expect(
|
||||
runSearch([['query', 'q']], {
|
||||
error: new RuntimeClientError('runtime_unavailable', 'Orca is not running.')
|
||||
})
|
||||
).rejects.toThrow('Orca is not running.')
|
||||
})
|
||||
|
||||
it('applies the paired-client exposure policy for a remote runtime', async () => {
|
||||
const { output } = await runSearch([['query', 'q']], { isRemote: true, json: true })
|
||||
|
||||
expect(printedResults(output).hits[0]).not.toHaveProperty('resumeCommand')
|
||||
expect(printedResults(output).hits[0]?.source).toEqual({ presence: 'present' })
|
||||
})
|
||||
|
||||
it('keeps the local resume command and source path for a same-machine host', async () => {
|
||||
const { output } = await runSearch([['query', 'q']], { json: true })
|
||||
|
||||
expect(printedResults(output).hits[0]?.resumeCommand).toBe('claude --resume session-1')
|
||||
expect(printedResults(output).hits[0]?.source).toEqual({
|
||||
presence: 'present',
|
||||
filePath: '/transcripts/session-1.jsonl'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca search --json', () => {
|
||||
it('hands back the contract response unchanged under the CLI envelope', async () => {
|
||||
const { output } = await runSearch([['query', 'q']], { json: true })
|
||||
const printed = printedEnvelope(output)
|
||||
|
||||
expect(printed.keys).toEqual(['id', 'ok', 'result', '_meta'])
|
||||
expect(printed.result).toEqual(resultsResponse)
|
||||
expect(JSON.stringify(printed.result)).toBe(JSON.stringify(resultsResponse))
|
||||
})
|
||||
|
||||
it('drops the debug block the caller did not ask for', async () => {
|
||||
const withDebug = {
|
||||
...resultsResponse,
|
||||
debug: {
|
||||
route: 'phrase' as const,
|
||||
plannerReport: { route: 'phrase' as const, scope: 'all' as const }
|
||||
}
|
||||
}
|
||||
const { output } = await runSearch([['query', 'q']], { json: true, result: withDebug })
|
||||
|
||||
expect(printedEnvelope(output).result).not.toHaveProperty('debug')
|
||||
})
|
||||
|
||||
it('keeps the debug block the caller asked for', async () => {
|
||||
const withDebug = {
|
||||
...resultsResponse,
|
||||
debug: {
|
||||
route: 'phrase' as const,
|
||||
plannerReport: { route: 'phrase' as const, scope: 'all' as const }
|
||||
}
|
||||
}
|
||||
const { output } = await runSearch(
|
||||
[
|
||||
['query', 'q'],
|
||||
['debug', true]
|
||||
],
|
||||
{ json: true, result: withDebug }
|
||||
)
|
||||
|
||||
expect(printedResults(output).debug).toEqual(withDebug.debug)
|
||||
})
|
||||
|
||||
it('hands back the status response unchanged', async () => {
|
||||
const { output } = await runSearch([['index-status', true]], {
|
||||
json: true,
|
||||
result: statusResponse
|
||||
})
|
||||
|
||||
expect(printedEnvelope(output).result).toEqual(statusResponse)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createSessionSearchClient } from '../../shared/ai-vault-search-client'
|
||||
import type { RuntimeRpcSuccess } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
formatSessionSearchResponse,
|
||||
formatSessionSearchStatus
|
||||
} from '../agent-session-search-format'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printResult } from '../format'
|
||||
import type { RuntimeClient } from '../runtime-client'
|
||||
import { RuntimeClientError } from '../runtime/types'
|
||||
import { parseSearchCommand } from '../search-command-arguments'
|
||||
|
||||
export const MALFORMED_CURSOR_MESSAGE =
|
||||
'The host did not recognise that --cursor value. Cursors belong to one query on one host; re-run the search without --cursor.'
|
||||
|
||||
/**
|
||||
* The shared contract client over the CLI's runtime RPC. Reusing it is what makes
|
||||
* an old host's unknown-method refusal an `unavailable/no-service` answer instead
|
||||
* of a raw JSON-RPC error, and it applies the same exposure policy the host did:
|
||||
* a paired runtime is a relay caller, a local one is not.
|
||||
*/
|
||||
function createCliSessionSearch(client: RuntimeClient) {
|
||||
let lastEnvelope: RuntimeRpcSuccess<unknown> | undefined
|
||||
const search = createSessionSearchClient(
|
||||
async (method, params) => {
|
||||
lastEnvelope = await client.call(method, params)
|
||||
return lastEnvelope.result
|
||||
},
|
||||
client.isRemote ? 'relay' : 'runtime'
|
||||
)
|
||||
// Why `client`: an answer synthesised from a refusal had no successful call, so
|
||||
// no runtime produced it and none of its identifiers may be claimed here.
|
||||
const envelope = <TResult>(result: TResult): RuntimeRpcSuccess<TResult> =>
|
||||
lastEnvelope
|
||||
? { ...lastEnvelope, result }
|
||||
: { id: 'local', ok: true, result, _meta: { runtimeId: 'client' } }
|
||||
return { search, envelope }
|
||||
}
|
||||
|
||||
/** `orca search` over `aiVault.searchSessions` / `aiVault.searchStatus` on one host. */
|
||||
export const SEARCH_HANDLERS: Record<string, CommandHandler> = {
|
||||
search: async ({ client, flags, json }) => {
|
||||
const command = parseSearchCommand(flags)
|
||||
const { search, envelope } = createCliSessionSearch(client)
|
||||
if (command.kind === 'index-status') {
|
||||
printResult(envelope(await search.searchStatus()), json, formatSessionSearchStatus)
|
||||
return
|
||||
}
|
||||
const response = await search.searchSessions(command.request)
|
||||
if (response.kind === 'malformed-cursor') {
|
||||
throw new RuntimeClientError('invalid_argument', MALFORMED_CURSOR_MESSAGE)
|
||||
}
|
||||
printResult(envelope(response), json, formatSessionSearchResponse)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import type { CommandSpec } from './args'
|
||||
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
|
||||
import { unknownCommandData } from './command-suggestion'
|
||||
import { formatCommandScopedFlagHelp } from './command-scoped-flag-help'
|
||||
import { FLAG_HELP_TEXT } from './flag-help-text'
|
||||
import { formatSkillsCommandFlagHelp } from './skills-command-flag-help'
|
||||
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
|
||||
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
|
||||
|
||||
@@ -74,9 +74,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
|
||||
|
||||
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
const command = commandPath.join(' ')
|
||||
const skillsHelp = formatSkillsCommandFlagHelp(command, flag)
|
||||
if (skillsHelp) {
|
||||
return skillsHelp
|
||||
const scopedHelp = formatCommandScopedFlagHelp(command, flag)
|
||||
if (scopedHelp) {
|
||||
return scopedHelp
|
||||
}
|
||||
if (command === 'terminal close' && flag === 'tab') {
|
||||
return '--tab Close the whole tab and wait for durable persistence'
|
||||
|
||||
+4
-1
@@ -83,7 +83,10 @@ export async function main(
|
||||
await runClaudeTeams(argv.slice(1), cwd)
|
||||
return
|
||||
}
|
||||
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, COMMAND_PATHS))
|
||||
const parsed = normalizeCommandPositionals(
|
||||
COMMAND_SPECS,
|
||||
parseArgs(argv, COMMAND_PATHS, COMMAND_SPECS)
|
||||
)
|
||||
const helpPath = resolveHelpPath(parsed)
|
||||
if (helpPath !== null) {
|
||||
printHelp(COMMAND_SPECS, helpPath)
|
||||
|
||||
@@ -14,6 +14,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
'Agent Discovery:',
|
||||
' agent-context Print the machine-readable command schema for agents',
|
||||
'',
|
||||
'Agent Sessions:',
|
||||
' search Search the full text of agent sessions on one Orca host',
|
||||
'',
|
||||
'Accounts:',
|
||||
' account add Add a managed Claude or Codex account on this Orca host',
|
||||
' account list List managed Claude and Codex accounts on this Orca host',
|
||||
|
||||
@@ -40,6 +40,8 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' orca status [--json]',
|
||||
' orca diagnostics memory [--json]',
|
||||
' orca agent-context [--json]',
|
||||
' orca search <query> [--scope conversation|all] [--fresh] [--limit <n>] [--cursor <c>] [--agent <id>] [--path <p>] [--since <iso>] [--sort relevance|newest] [--debug] [--json]',
|
||||
' orca search --index-status [--json]',
|
||||
' orca account add [--agent claude|codex] [--json]',
|
||||
' orca account list [--json]',
|
||||
' orca host list [--json]',
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
AI_VAULT_AGENTS,
|
||||
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
|
||||
type AiVaultAgent
|
||||
} from '../shared/ai-vault-types'
|
||||
import { AiVaultSearchFiltersSchema } from '../shared/ai-vault-search-contract'
|
||||
import type { AiVaultSearchRequest } from '../shared/ai-vault-search-types'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRepeatedStringFlag
|
||||
} from './flags'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
|
||||
export type SearchCommand =
|
||||
| { kind: 'index-status' }
|
||||
| { kind: 'search'; request: AiVaultSearchRequest }
|
||||
|
||||
// Why enumerated: --index-status calls a different RPC that reads none of these,
|
||||
// so ignoring one would answer a question the caller did not ask.
|
||||
const QUERY_ONLY_FLAGS = [
|
||||
'query',
|
||||
'scope',
|
||||
'fresh',
|
||||
'limit',
|
||||
'cursor',
|
||||
'agent',
|
||||
'path',
|
||||
'since',
|
||||
'sort',
|
||||
'debug'
|
||||
] as const
|
||||
|
||||
function readEnum<TValue extends string>(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
allowed: readonly TValue[]
|
||||
): TValue | undefined {
|
||||
const value = getOptionalStringFlag(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
// Why find and not includes: the match carries the narrow type, so nothing is asserted.
|
||||
const matched = allowed.find((candidate) => candidate === value)
|
||||
if (matched === undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unsupported --${name} "${value}". Use ${allowed.join(' or ')}.`
|
||||
)
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
const KNOWN_AGENTS = new Set<string>(AI_VAULT_AGENTS)
|
||||
|
||||
function isAiVaultAgent(value: string): value is AiVaultAgent {
|
||||
return KNOWN_AGENTS.has(value)
|
||||
}
|
||||
|
||||
function readAgents(flags: Map<string, string | boolean>): AiVaultAgent[] | undefined {
|
||||
const agents = getRepeatedStringFlag(flags, 'agent')
|
||||
if (agents.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const unknown = agents.filter((agent) => !isAiVaultAgent(agent))
|
||||
if (unknown.length > 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown --agent ${unknown.map((agent) => `"${agent}"`).join(', ')}. Known agents: ${AI_VAULT_AGENTS.join(', ')}.`
|
||||
)
|
||||
}
|
||||
return agents.filter(isAiVaultAgent)
|
||||
}
|
||||
|
||||
function readScopePaths(flags: Map<string, string | boolean>): string[] | undefined {
|
||||
const paths = getRepeatedStringFlag(flags, 'path')
|
||||
if (paths.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (paths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Too many --path values (${paths.length}); at most ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} are accepted.`
|
||||
)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Why the contract's own schema: the offset requirement lives there, and a
|
||||
// second copy here would drift from what the host accepts.
|
||||
function readSince(flags: Map<string, string | boolean>): string | undefined {
|
||||
const since = getOptionalStringFlag(flags, 'since')
|
||||
if (since === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!AiVaultSearchFiltersSchema.shape.since.safeParse(since).success) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Invalid --since "${since}". Use an ISO 8601 timestamp with an offset, for example 2026-08-01T00:00:00Z.`
|
||||
)
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
function readQuery(flags: Map<string, string | boolean>): string {
|
||||
const query = getOptionalStringFlag(flags, 'query')
|
||||
if (query === undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Missing a search query. Pass it as `orca search "<query>"` or --query "<query>", or ask for the index report with --index-status.'
|
||||
)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function readFilters(
|
||||
flags: Map<string, string | boolean>
|
||||
): AiVaultSearchRequest['filters'] | undefined {
|
||||
const agents = readAgents(flags)
|
||||
const scopePaths = readScopePaths(flags)
|
||||
const since = readSince(flags)
|
||||
const sort = readEnum(flags, 'sort', ['relevance', 'newest'] as const)
|
||||
const filters = {
|
||||
...(agents ? { agents } : {}),
|
||||
...(scopePaths ? { scopePaths } : {}),
|
||||
...(since ? { since } : {}),
|
||||
...(sort ? { sort } : {})
|
||||
}
|
||||
return Object.keys(filters).length > 0 ? filters : undefined
|
||||
}
|
||||
|
||||
/** Maps `orca search` flags onto the session-search contract; nothing it does not have. */
|
||||
export function parseSearchCommand(flags: Map<string, string | boolean>): SearchCommand {
|
||||
if (flags.has('index-status')) {
|
||||
const conflicting = QUERY_ONLY_FLAGS.filter((flag) => flags.has(flag))
|
||||
if (conflicting.length > 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--index-status reports on the index and takes no query, so it cannot be combined with ${conflicting.map((flag) => `--${flag}`).join(', ')}.`
|
||||
)
|
||||
}
|
||||
return { kind: 'index-status' }
|
||||
}
|
||||
|
||||
const query = readQuery(flags)
|
||||
const scope = readEnum(flags, 'scope', ['conversation', 'all'] as const)
|
||||
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
const cursor = getOptionalStringFlag(flags, 'cursor')
|
||||
const filters = readFilters(flags)
|
||||
return {
|
||||
kind: 'search',
|
||||
request: {
|
||||
query,
|
||||
...(scope ? { scope } : {}),
|
||||
...(flags.has('fresh') ? { freshness: 'wait-until-current' as const } : {}),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(filters ? { filters } : {}),
|
||||
...(flags.has('debug') ? { debug: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/** Per-flag help for the skills commands, kept out of the shared help chain it would crowd. */
|
||||
const SKILLS_FLAG_HELP: Record<string, Record<string, string>> = {
|
||||
'skills get': {
|
||||
full: '--full Print the full guide with bundled references',
|
||||
reference: '--reference <name> Print one bundled reference by name',
|
||||
references: '--references List the bundled reference names for a topic'
|
||||
},
|
||||
'skills install': {
|
||||
agent: '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSkillsCommandFlagHelp(command: string, flag: string): string | undefined {
|
||||
return SKILLS_FLAG_HELP[command]?.[flag]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { LINEAR_COMMAND_SPECS } from './linear'
|
||||
import { VM_COMMAND_SPECS } from './vm'
|
||||
import { SKILL_COMMAND_SPECS } from './skills'
|
||||
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
|
||||
import { SEARCH_COMMAND_SPECS } from './search'
|
||||
|
||||
export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...CORE_COMMAND_SPECS,
|
||||
@@ -36,5 +37,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...LINEAR_COMMAND_SPECS,
|
||||
...VM_COMMAND_SPECS,
|
||||
...EMULATOR_COMMAND_SPECS,
|
||||
...SKILL_COMMAND_SPECS
|
||||
...SKILL_COMMAND_SPECS,
|
||||
...SEARCH_COMMAND_SPECS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SEARCH_COMMAND_SPECS } from './search'
|
||||
import { effectiveAllowedFlags, findCommandSpec, GLOBAL_FLAGS } from '../args'
|
||||
import { buildAgentContext } from '../agent-context'
|
||||
import { suggestCommands } from '../command-suggestion'
|
||||
import { HANDLER_COMMAND_KEYS } from '../dispatch'
|
||||
import { formatCommandHelp, printHelp } from '../help'
|
||||
import { ROOT_HELP_TEXT_PRIMARY } from '../root-help-text-primary'
|
||||
import { ROOT_HELP_TEXT_SECONDARY } from '../root-help-text-secondary'
|
||||
import { COMMAND_SPECS } from './index'
|
||||
import { CLI_COMMAND_NAMES } from '../../main/startup/cli-command-names'
|
||||
|
||||
const searchSpec = SEARCH_COMMAND_SPECS[0]!
|
||||
const help = formatCommandHelp(searchSpec)
|
||||
|
||||
describe('orca search command spec', () => {
|
||||
it('is one command, not a group, because the query is a bare positional', () => {
|
||||
expect(SEARCH_COMMAND_SPECS).toHaveLength(1)
|
||||
expect(searchSpec.path).toEqual(['search'])
|
||||
expect(searchSpec.positionalArgs).toEqual(['query'])
|
||||
})
|
||||
|
||||
it('is registered in the live spec table, the dispatcher and the launch redirect', () => {
|
||||
expect(COMMAND_SPECS).toContain(searchSpec)
|
||||
expect(HANDLER_COMMAND_KEYS.has('search')).toBe(true)
|
||||
expect(CLI_COMMAND_NAMES).toContain('search')
|
||||
})
|
||||
|
||||
it('accepts exactly the flags that map onto the search contract', () => {
|
||||
expect([...searchSpec.allowedFlags].sort()).toEqual([
|
||||
'agent',
|
||||
'cursor',
|
||||
'debug',
|
||||
'environment',
|
||||
'fresh',
|
||||
'help',
|
||||
'index-status',
|
||||
'json',
|
||||
'limit',
|
||||
'pairing-code',
|
||||
'path',
|
||||
'query',
|
||||
'scope',
|
||||
'since',
|
||||
'sort'
|
||||
])
|
||||
})
|
||||
|
||||
it('declares --agent and --path repeatable for this command only', () => {
|
||||
expect(searchSpec.repeatableFlags).toEqual(['agent', 'path'])
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
if (spec !== searchSpec) {
|
||||
expect(spec.repeatableFlags).toBeUndefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not accept or advertise browser page targeting', () => {
|
||||
expect(effectiveAllowedFlags(searchSpec)).not.toContain('page')
|
||||
expect(help).not.toContain('--page')
|
||||
})
|
||||
|
||||
it('describes every search flag rather than falling back to the bare name', () => {
|
||||
const searchOnly = searchSpec.allowedFlags.filter((flag) => !GLOBAL_FLAGS.includes(flag))
|
||||
expect(searchOnly).toHaveLength(11)
|
||||
for (const flag of searchOnly) {
|
||||
expect(help).toContain(`--${flag}`)
|
||||
expect(help.split('\n')).not.toContain(` --${flag}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('describes --agent as a search filter, not a terminal agent to launch', () => {
|
||||
expect(help).toContain('Restrict to one agent; repeat for several')
|
||||
expect(help).not.toContain('TUI agent')
|
||||
})
|
||||
|
||||
it('tells the reader to quote a multi-word query', () => {
|
||||
expect(searchSpec.notes?.join('\n')).toContain('Quote a multi-word query')
|
||||
})
|
||||
|
||||
it('states that it searches one host and offers no all-computers search', () => {
|
||||
expect(searchSpec.notes?.join('\n')).toContain('There is no all-computers search.')
|
||||
})
|
||||
|
||||
it('shows both the query and the index report in its usage', () => {
|
||||
expect(searchSpec.usage).toContain('orca search <query>')
|
||||
expect(searchSpec.usage).toContain('orca search --index-status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca search discovery surfaces', () => {
|
||||
it('is listed in the root help', () => {
|
||||
expect(ROOT_HELP_TEXT_PRIMARY).toContain('Agent Sessions:')
|
||||
expect(ROOT_HELP_TEXT_PRIMARY).toContain(
|
||||
' search Search the full text of agent sessions on one Orca host'
|
||||
)
|
||||
expect(ROOT_HELP_TEXT_SECONDARY).toContain(' orca search --index-status [--json]')
|
||||
})
|
||||
|
||||
it('prints its own help for `orca search --help`', () => {
|
||||
const lines: string[] = []
|
||||
const restore = console.log
|
||||
console.log = (value: unknown) => void lines.push(String(value))
|
||||
try {
|
||||
printHelp(COMMAND_SPECS, ['search'])
|
||||
} finally {
|
||||
console.log = restore
|
||||
}
|
||||
expect(lines.join('\n')).toContain('Usage: orca search <query>')
|
||||
})
|
||||
|
||||
it('resolves for dispatch', () => {
|
||||
expect(findCommandSpec(COMMAND_SPECS, ['search'])).toBe(searchSpec)
|
||||
})
|
||||
|
||||
it('exposes the command to agent discovery with its positional and flags', () => {
|
||||
const command = buildAgentContext(COMMAND_SPECS).commands.find(
|
||||
(entry) => entry.command === 'search'
|
||||
)
|
||||
expect(command?.positionalArgs).toEqual(['query'])
|
||||
expect(command?.flags).toContain('index-status')
|
||||
expect(command?.flags).not.toContain('page')
|
||||
})
|
||||
|
||||
it('is offered as a suggestion for a near-miss command', () => {
|
||||
expect(suggestCommands(COMMAND_SPECS, ['serch'])).toContain('search')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GLOBAL_FLAGS, type CommandSpec } from '../args'
|
||||
|
||||
// Why one command and not a `search status` subcommand: the query is a bare
|
||||
// positional, so `orca search status` would be indistinguishable from searching
|
||||
// for the word "status". The index report is a flag on the same command instead.
|
||||
export const SEARCH_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['search'],
|
||||
summary: 'Search the full text of agent sessions indexed on the selected Orca host',
|
||||
usage:
|
||||
'orca search <query> [--scope conversation|all] [--fresh] [--limit <n>] [--cursor <c>] [--agent <id>] [--path <p>] [--since <iso>] [--sort relevance|newest] [--debug] [--json]\n orca search --index-status [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'query',
|
||||
'scope',
|
||||
'fresh',
|
||||
'limit',
|
||||
'cursor',
|
||||
'agent',
|
||||
'path',
|
||||
'since',
|
||||
'sort',
|
||||
'debug',
|
||||
'index-status'
|
||||
],
|
||||
repeatableFlags: ['agent', 'path'],
|
||||
positionalArgs: ['query'],
|
||||
notes: [
|
||||
'Searches one host: this machine, or the paired Orca server named by --environment / --pairing-code. There is no all-computers search.',
|
||||
'In an Orca SSH terminal, the forwarded CLI searches the controlling Orca runtime by default. Use --environment / --pairing-code to select a paired server; --path only filters results on the selected runtime.',
|
||||
'Quote a multi-word query, or pass it as --query "<text>"; unquoted words are read as command names.',
|
||||
'--scope conversation searches user and assistant turns only; --scope all (the default) also searches commands and tool output.',
|
||||
'--fresh waits up to five seconds for the host to reconcile its index before searching, then searches anyway.',
|
||||
'--agent and --path may be repeated. --path is a literal execution-host path and is not expanded or resolved against the current directory.',
|
||||
'--since takes an ISO 8601 timestamp with an offset, for example 2026-08-01T00:00:00Z.',
|
||||
'--limit is per page (default 20, maximum 100). Pass the printed cursor back with --cursor to read the next page.',
|
||||
'A cursor belongs to one query on one host. Change the query, the filters, or the host and the cursor stops being valid.',
|
||||
'Resume commands and source paths are printed only for a host on this machine; a paired server withholds them.',
|
||||
'--json prints the runtime response envelope with the search contract answer under `result`.'
|
||||
],
|
||||
examples: [
|
||||
'orca search "strict mode violation getByRole"',
|
||||
'orca search resolveTerminalPath --agent claude --sort newest',
|
||||
'orca search "kernel panic" --path /Users/me/orca --since 2026-08-01T00:00:00Z --json',
|
||||
'orca search "kernel panic" --limit 50 --cursor eyJ2IjoxfQ',
|
||||
'orca search --index-status',
|
||||
'orca search "flaky test" --environment build-server'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -118,7 +118,21 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
})
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions: new Map([
|
||||
['session', { journal, params: { location: { workspaceId }, provider: agent } }]
|
||||
[
|
||||
'session',
|
||||
{
|
||||
journal,
|
||||
params: {
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId,
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: agent
|
||||
}
|
||||
}
|
||||
]
|
||||
]),
|
||||
getRecord: () => null,
|
||||
now: () => 1,
|
||||
@@ -193,7 +207,12 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
]
|
||||
})
|
||||
} as unknown as AgentSessionJournal
|
||||
const location = { workspaceId, workspaceKind: 'git-worktree' as const }
|
||||
const location = {
|
||||
executionHostId: 'local' as const,
|
||||
wslDistro: null,
|
||||
workspaceId,
|
||||
workspaceKind: 'git-worktree' as const
|
||||
}
|
||||
const pending: Promise<void>[] = []
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]),
|
||||
|
||||
@@ -707,10 +707,7 @@ describe('wrapWindowsHookCommand', () => {
|
||||
|
||||
describe('wrapWindowsCmdHookCommand', () => {
|
||||
it('returns the bare, directly-spawnable path for a cmd-safe managed script', () => {
|
||||
// Why: Codex/Antigravity/Devin launch the command as a program (argv[0]),
|
||||
// not via cmd.exe, so the launcher must be a single spawnable token — a bare
|
||||
// .cmd path. A cmd-builtin `if …` launcher has argv[0] = `if`, which is
|
||||
// unspawnable and fails every hook with exit 1 (#8430 regression).
|
||||
// Direct-spawn consumers need a launchable argv[0], not a cmd builtin such as `if`.
|
||||
const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
|
||||
const command = wrapWindowsCmdHookCommand(scriptPath)
|
||||
expect(command).toBe(scriptPath)
|
||||
@@ -721,12 +718,7 @@ describe('wrapWindowsCmdHookCommand', () => {
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'resolves the launcher to a real executable file, not a shell fragment',
|
||||
() => {
|
||||
// Regression guard for #8430: Codex/Antigravity/Devin spawn the launcher as
|
||||
// a program (argv[0]), so it must be an existing, launchable file. The broken
|
||||
// `if exist … (call …)` form had argv[0] = `if` — a cmd builtin, not a file —
|
||||
// which is unspawnable and failed every hook. The bare path is the file.
|
||||
// win32-only: the real temp path is cmd-safe only with backslashes; a POSIX
|
||||
// tmpDir has `/`, which routes to the encoded fallback by design.
|
||||
// POSIX temp paths contain `/`, which selects the encoded fallback instead.
|
||||
const scriptPath = join(tmpDir, 'codex-hook.cmd')
|
||||
writeFileSync(scriptPath, '@echo off\r\nexit /b 0\r\n', 'utf-8')
|
||||
const command = wrapWindowsCmdHookCommand(scriptPath)
|
||||
|
||||
@@ -118,6 +118,16 @@ export {
|
||||
} from './windows-powershell-hook-launcher'
|
||||
|
||||
export function wrapWindowsHookCommand(
|
||||
scriptPath: string,
|
||||
env: Record<string, string> = {},
|
||||
options: { fallbackStdout?: string } = {}
|
||||
): string {
|
||||
return wrapWindowsPowerShellEncodedCommand(
|
||||
buildWindowsHookPowerShellCommand(scriptPath, env, options)
|
||||
)
|
||||
}
|
||||
|
||||
export function buildWindowsHookPowerShellCommand(
|
||||
scriptPath: string,
|
||||
env: Record<string, string> = {},
|
||||
// Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462).
|
||||
@@ -135,14 +145,13 @@ export function wrapWindowsHookCommand(
|
||||
// Why the order: answer first (a gate event reads silence as deny), then the shared
|
||||
// env guard, and only then own stdin — outside an Orca pane the caller may abandon the
|
||||
// pipe, and ReadToEnd would strand the launcher there forever (#11549).
|
||||
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
return wrapWindowsPowerShellEncodedCommand(command)
|
||||
return `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
}
|
||||
|
||||
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
|
||||
|
||||
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
|
||||
// Why: Codex/Antigravity/Devin spawn the hook as argv[0], not via cmd.exe, so it must be one spawnable token; a cmd `if exist` launcher isn't (#8430).
|
||||
// Direct-spawn consumers need one executable token; a cmd `if exist` fragment is not one (#8430).
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,12 @@ describe('managed hook command contract', () => {
|
||||
expect(commands.length).toBeGreaterThan(0)
|
||||
for (const command of commands) {
|
||||
expect(command.length).toBeGreaterThan(0)
|
||||
expect(findBareHookCommandVariables(command), command).toEqual([])
|
||||
// Native Windows Codex evaluates PowerShell variables without Grok's dollar-byte scanner.
|
||||
const scannedCommand =
|
||||
agent === 'codex' && platform === 'win32' && command.startsWith('if (Test-Path')
|
||||
? command.replaceAll('$LASTEXITCODE', '').replaceAll('$env:', '')
|
||||
: command
|
||||
expect(findBareHookCommandVariables(scannedCommand), command).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -24,6 +25,15 @@ vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
}))
|
||||
|
||||
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
|
||||
const SUBJECT = makeStructuredAgentStatusSubject(
|
||||
{
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'repo-1::/workspace/app',
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
SESSION
|
||||
)
|
||||
const TAB = structuredAgentSessionTabId(SESSION)
|
||||
const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION)
|
||||
const OBSERVED_AT = 1_757_030_400_000
|
||||
@@ -59,7 +69,7 @@ afterEach(() => {
|
||||
describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
it('stores the projection as a row under the pane key the renderer derives', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -86,22 +96,25 @@ describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
// The same mapping the sidebar applies, so the two surfaces cannot disagree about one session.
|
||||
it('maps attention to blocked and idle to done', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary({ status: 'attention' }))
|
||||
server.ingestStructuredStatus(summary({ status: 'attention' }), SUBJECT)
|
||||
expect(server.getStatusSnapshot()[0]?.state).toBe('blocked')
|
||||
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }))
|
||||
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }), SUBJECT)
|
||||
expect(server.getStatusSnapshot()[0]?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('marks a session whose provider child is gone as held, not owned', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined }))
|
||||
server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined }), SUBJECT)
|
||||
expect(server.getStatusSnapshot()[0]?.structuredHost).toBe('held')
|
||||
})
|
||||
|
||||
it('keeps the state start while later evidence of the same state arrives', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.ingestStructuredStatus(summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 }))
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
server.ingestStructuredStatus(
|
||||
summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 }),
|
||||
SUBJECT
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
toolName: 'read',
|
||||
@@ -113,18 +126,18 @@ describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
// Null status means no turn has been persisted; the chat shows nothing, so neither does this.
|
||||
it('holds no row for a session without a persisted turn, and drops one that regresses to none', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary({ status: null }))
|
||||
server.ingestStructuredStatus(summary({ status: null }), SUBJECT)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.ingestStructuredStatus(summary({ status: null }))
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
server.ingestStructuredStatus(summary({ status: null }), SUBJECT)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the row when the host stops holding the session', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.dropStructuredStatus(SESSION)
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
server.dropStructuredStatus(SUBJECT)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -136,13 +149,13 @@ describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
const withProviderSession = summary({
|
||||
providerSession: { key: 'session_id', id: 'codex-thread-1' }
|
||||
})
|
||||
server.ingestStructuredStatus(withProviderSession)
|
||||
server.ingestStructuredStatus(withProviderSession, SUBJECT)
|
||||
expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({
|
||||
key: 'session_id',
|
||||
id: 'codex-thread-1'
|
||||
})
|
||||
|
||||
server.dropStructuredStatus(SESSION)
|
||||
server.dropStructuredStatus(SUBJECT)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -157,8 +170,8 @@ describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
server.setPaneStatusClearListener((clear) => cleared.push(clear))
|
||||
server.subscribeStatusDrop((paneKey) => dropped.push(paneKey))
|
||||
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.dropStructuredStatus(SESSION)
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
server.dropStructuredStatus(SUBJECT)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
expect(cleared).toEqual([])
|
||||
@@ -175,7 +188,7 @@ describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
original()
|
||||
}
|
||||
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
expect(persists).toHaveLength(0)
|
||||
|
||||
server.ingestTerminalStatus({
|
||||
@@ -193,7 +206,7 @@ describe('AgentHookServer ingestStructuredStatus', () => {
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' }
|
||||
})
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
|
||||
const byPane = new Map(server.getStatusSnapshot().map((row) => [row.paneKey, row]))
|
||||
expect(byPane.get(PANE)?.structuredHost).toBeUndefined()
|
||||
@@ -227,7 +240,7 @@ describe('structured rows and last-status.json', () => {
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' }
|
||||
})
|
||||
server.ingestStructuredStatus(summary())
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
server.flushStatusPersistSync()
|
||||
} finally {
|
||||
server.stop()
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
makeStructuredAgentStatusSubject,
|
||||
type AgentStatusExecutionScope,
|
||||
type AgentStatusStructuredSessionSubject
|
||||
} from '../../shared/agent-status-subject'
|
||||
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../shared/structured-agent-session-projection'
|
||||
import { AgentHookServer } from './server'
|
||||
import { GOOD_PANE, PANE } from './server.test-fixtures'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
|
||||
|
||||
const SESSION = 'canonical-session-one'
|
||||
const SCOPE: AgentStatusExecutionScope = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-one',
|
||||
workspaceKind: 'git-worktree'
|
||||
}
|
||||
const SUBJECT = makeStructuredAgentStatusSubject(SCOPE, SESSION)
|
||||
const PANE_KEY = structuredAgentSessionPaneKey(structuredAgentSessionTabId(SESSION), SESSION)
|
||||
|
||||
function summary(
|
||||
subject: AgentStatusStructuredSessionSubject = SUBJECT
|
||||
): AgentSessionStatusSummary {
|
||||
return {
|
||||
sessionId: subject.sessionId,
|
||||
workspaceId: subject.workspaceId,
|
||||
agent: 'codex',
|
||||
status: 'working',
|
||||
hostExecutionOwned: true,
|
||||
latestPrompt: 'trusted journal',
|
||||
updatedAt: 100
|
||||
}
|
||||
}
|
||||
|
||||
function terminal(server: AgentHookServer, paneKey: string): void {
|
||||
server.ingestTerminalStatus({
|
||||
paneKey,
|
||||
worktreeId: SCOPE.workspaceId,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'legacy PTY', agentType: 'claude' }
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('structured canonical production slice', () => {
|
||||
it('stores once canonically and supplies every legacy reader from that row', () => {
|
||||
const server = new AgentHookServer()
|
||||
const changed = vi.fn()
|
||||
const enriched = vi.fn()
|
||||
server.subscribeStatusChanges(changed)
|
||||
server.subscribeEnrichedStatus(enriched)
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toEqual([
|
||||
expect.objectContaining({
|
||||
subject: SUBJECT,
|
||||
status: expect.objectContaining({ paneKey: PANE_KEY })
|
||||
})
|
||||
])
|
||||
expect(server.getStatusSnapshotForPane(PANE_KEY)).toEqual(server.getStatusSnapshot())
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE_KEY,
|
||||
state: 'working',
|
||||
observedInCurrentRuntime: true
|
||||
})
|
||||
])
|
||||
expect(enriched).toHaveBeenCalledOnce()
|
||||
const replay = vi.fn()
|
||||
server.setListener(replay)
|
||||
expect(replay).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ paneKey: PANE_KEY, isReplay: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps mixed legacy enumeration in original insertion order through updates and re-admission', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(200)
|
||||
const server = new AgentHookServer()
|
||||
const second = makeStructuredAgentStatusSubject(SCOPE, 'canonical-session-two')
|
||||
const secondPane = structuredAgentSessionPaneKey(
|
||||
structuredAgentSessionTabId(second.sessionId),
|
||||
second.sessionId
|
||||
)
|
||||
const baseline = new Map<string, string>()
|
||||
terminal(server, PANE)
|
||||
baseline.set(PANE, 'legacy PTY')
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
baseline.set(PANE_KEY, 'trusted journal')
|
||||
terminal(server, GOOD_PANE)
|
||||
baseline.set(GOOD_PANE, 'legacy PTY')
|
||||
server.ingestStructuredStatus(summary(second), second)
|
||||
baseline.set(secondPane, 'trusted journal')
|
||||
terminal(server, PANE)
|
||||
server.ingestStructuredStatus({ ...summary(), latestPrompt: 'updated' }, SUBJECT)
|
||||
baseline.set(PANE_KEY, 'updated')
|
||||
const listing = () => server.getStatusSnapshot().map((row) => [row.paneKey, row.prompt])
|
||||
expect(listing()).toEqual([...baseline])
|
||||
expect(server.getStatusChangeSnapshot().map((row) => row.paneKey)).toEqual([...baseline.keys()])
|
||||
const replay: string[] = []
|
||||
server.setListener((entry) => replay.push(entry.paneKey))
|
||||
expect(replay).toEqual([...baseline.keys()])
|
||||
server.dropStructuredStatus(SUBJECT)
|
||||
baseline.delete(PANE_KEY)
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
baseline.set(PANE_KEY, 'trusted journal')
|
||||
expect(listing()).toEqual([...baseline])
|
||||
const relocated = makePaneKey('relocated-tab', '88888888-8888-4888-8888-888888888888')
|
||||
server.transferPaneAuthority(PANE, relocated, undefined, 200, { authorityVerified: true })
|
||||
baseline.delete(PANE)
|
||||
baseline.set(relocated, 'legacy PTY')
|
||||
expect(listing()).toEqual([...baseline])
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(2)
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('isolates identical session identifiers across host, WSL and workspace kind scopes', () => {
|
||||
const server = new AgentHookServer()
|
||||
const scopes: AgentStatusExecutionScope[] = [
|
||||
SCOPE,
|
||||
{ ...SCOPE, wslDistro: 'Ubuntu' },
|
||||
{ ...SCOPE, wslDistro: 'Debian' },
|
||||
{ ...SCOPE, executionHostId: 'ssh:first' },
|
||||
{ ...SCOPE, executionHostId: 'ssh:second' },
|
||||
{ ...SCOPE, executionHostId: 'runtime:paired' },
|
||||
{ ...SCOPE, workspaceKind: 'folder' }
|
||||
]
|
||||
const subjects = scopes.map((scope) => makeStructuredAgentStatusSubject(scope, SESSION))
|
||||
for (const subject of subjects) {
|
||||
server.ingestStructuredStatus(summary(subject), subject)
|
||||
}
|
||||
expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual(subjects)
|
||||
server.dropStructuredStatus(SUBJECT)
|
||||
expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual(
|
||||
subjects.slice(1)
|
||||
)
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects missing or mismatched structured scope without fabricating a parent', () => {
|
||||
const server = new AgentHookServer()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: models a caller at an untyped boundary (e.g. IPC) invoking with fewer arguments than the method declares; no typed call expresses a missing required parameter.
|
||||
const ingestMissingSubject = server.ingestStructuredStatus.bind(server) as unknown as (
|
||||
summary: AgentSessionStatusSummary
|
||||
) => void
|
||||
expect(() => ingestMissingSubject(summary())).toThrow('trusted owner subject')
|
||||
expect(() =>
|
||||
server.ingestStructuredStatus({ ...summary(), workspaceId: 'other' }, SUBJECT)
|
||||
).toThrow('trusted owner subject')
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses late PTY and relay evidence at a canonically owned address without fanout', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
const before = server.getCanonicalStatusSnapshot()
|
||||
const changed = vi.fn()
|
||||
const enriched = vi.fn()
|
||||
server.subscribeStatusChanges(changed)
|
||||
server.subscribeEnrichedStatus(enriched)
|
||||
terminal(server, PANE_KEY)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE_KEY, payload: { state: 'done', prompt: 'late', agentType: 'claude' } },
|
||||
'ssh-route'
|
||||
)
|
||||
expect(server.getCanonicalStatusSnapshot()).toEqual(before)
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
|
||||
expect(server.getStatusSnapshot()).toHaveLength(1)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
expect(enriched).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a canonical address already occupied by unbound legacy evidence', () => {
|
||||
const server = new AgentHookServer()
|
||||
terminal(server, PANE_KEY)
|
||||
expect(() => server.ingestStructuredStatus(summary(), SUBJECT)).toThrow(
|
||||
'conflicts with legacy evidence'
|
||||
)
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE_KEY, prompt: 'legacy PTY' })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps incomplete remote evidence exclusively legacy and pane cleanup cannot remove a canonical row', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, payload: { state: 'working', prompt: 'remote', agentType: 'claude' } },
|
||||
'ssh-route'
|
||||
)
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
connectionId: 'ssh-route',
|
||||
paneKey: PANE
|
||||
})
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
server.dropStatusEntry(PANE_KEY)
|
||||
server.retirePaneAuthority(PANE_KEY)
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(1)
|
||||
expect(server.getStatusSnapshotForPane(PANE_KEY)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears canonical state and renews the owner epoch when the server stops', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestStructuredStatus(summary(), SUBJECT)
|
||||
const epoch = server.getCanonicalStatusSnapshot().epoch
|
||||
server.stop()
|
||||
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
|
||||
expect(server.getCanonicalStatusSnapshot().epoch).not.toBe(epoch)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@ export const _internals = {
|
||||
parseFormEncodedBody,
|
||||
resetCachesForTests: (): void => {
|
||||
clearAllListenerCaches(agentHookServer._getStateForTests())
|
||||
agentHookServer._resetCanonicalStatusForTests()
|
||||
agentHookServer._resetRowOwnershipForTests()
|
||||
agentHookServer._resetPromptSentDedupeForTests()
|
||||
agentHookServer._resetConnectionTimestampWatermarksForTests()
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
|
||||
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
|
||||
import {
|
||||
parseAgentStatusSubject,
|
||||
serializeAgentStatusSubject,
|
||||
type AgentStatusStructuredSessionSubject
|
||||
} from '../../../shared/agent-status-subject'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionStatusState,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
import { structuredStatusLegacyEvent } from './server-structured-status-row'
|
||||
import { AgentHookServerIngestTerminal } from './server-ingest-terminal'
|
||||
|
||||
/**
|
||||
* Structured (native chat) sessions have no PTY and no hook script, so nothing else reaches this
|
||||
* store for them. The host projects each session's journal into a summary; this is where that
|
||||
* summary becomes the same row every other agent has, keyed by the pane key the renderer derives.
|
||||
*/
|
||||
export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal {
|
||||
ingestStructuredStatus(summary: AgentSessionStatusSummary): void {
|
||||
const paneKey = structuredStatusPaneKey(summary.sessionId)
|
||||
// No persisted turn yet: the chat shows nothing, so neither does any status reader.
|
||||
ingestStructuredStatus(
|
||||
summary: AgentSessionStatusSummary,
|
||||
subject: AgentStatusStructuredSessionSubject
|
||||
): void {
|
||||
const parsed = parseAgentStatusSubject(subject)
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.kind !== 'structured-session' ||
|
||||
parsed.sessionId !== summary.sessionId ||
|
||||
parsed.workspaceId !== summary.workspaceId ||
|
||||
!Number.isFinite(summary.updatedAt) ||
|
||||
summary.updatedAt < 0
|
||||
) {
|
||||
throw new Error('Structured status does not match its trusted owner subject')
|
||||
}
|
||||
if (!summary.status) {
|
||||
this.dropStructuredStatus(summary.sessionId)
|
||||
this.dropStructuredStatus(parsed)
|
||||
return
|
||||
}
|
||||
if (this.getAgentStatusDisposition(paneKey) !== 'accept') {
|
||||
return
|
||||
const previous = this.canonicalStatusStore.getParent(parsed)
|
||||
const priorStatus = previous?.status
|
||||
const state = structuredAgentSessionStatusState(summary.status)
|
||||
const tabId = structuredAgentSessionTabId(parsed.sessionId)
|
||||
const paneKey = structuredAgentSessionPaneKey(tabId, parsed.sessionId)
|
||||
if (this.state.lastStatusByPaneKey.has(paneKey)) {
|
||||
throw new Error('Structured status address conflicts with legacy evidence')
|
||||
}
|
||||
const payload: ParsedAgentStatusPayload = {
|
||||
state: structuredAgentSessionStatusState(summary.status),
|
||||
const snapshot = this.canonicalStatusStore.getSnapshot()
|
||||
const status: AgentStatusIpcPayload = {
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId: parsed.workspaceId,
|
||||
connectionId: null,
|
||||
structuredHost: summary.hostExecutionOwned ? 'owned' : 'held',
|
||||
...(summary.providerSession ? { providerSession: summary.providerSession } : {}),
|
||||
state,
|
||||
prompt: summary.latestPrompt,
|
||||
agentType: summary.agent,
|
||||
...(summary.model ? { model: summary.model } : {}),
|
||||
@@ -32,35 +57,71 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
|
||||
...(summary.toolInput ? { toolInput: summary.toolInput } : {}),
|
||||
...(summary.lastAssistantMessage
|
||||
? { lastAssistantMessage: summary.lastAssistantMessage }
|
||||
: {})
|
||||
: {}),
|
||||
receivedAt: Math.max(Date.now(), priorStatus?.receivedAt ?? 0),
|
||||
evidenceObservedAt: summary.updatedAt,
|
||||
stateStartedAt: priorStatus?.state === state ? priorStatus.stateStartedAt : summary.updatedAt,
|
||||
observation: {
|
||||
origin: 'structured',
|
||||
kind: 'transition',
|
||||
authorityId: snapshot.epoch,
|
||||
incarnation: 0,
|
||||
revision: snapshot.revision + 1,
|
||||
observedAt: summary.updatedAt
|
||||
}
|
||||
}
|
||||
// The journal clock stamps the evidence so a restart's republish does not read as fresh work.
|
||||
this.applyNormalizedStatus(
|
||||
{
|
||||
paneKey,
|
||||
tabId: structuredAgentSessionTabId(summary.sessionId),
|
||||
worktreeId: summary.workspaceId,
|
||||
connectionId: null,
|
||||
structuredHost: summary.hostExecutionOwned ? 'owned' : 'held',
|
||||
...(summary.providerSession ? { providerSession: summary.providerSession } : {}),
|
||||
payload
|
||||
},
|
||||
undefined,
|
||||
'structured',
|
||||
summary.updatedAt
|
||||
)
|
||||
const publication = this.canonicalStatusStore.applyMutation({
|
||||
parent: { subject: parsed, status, firstObservedAt: previous?.firstObservedAt ?? Date.now() }
|
||||
})
|
||||
if (!publication) {
|
||||
return
|
||||
}
|
||||
const key = serializeAgentStatusSubject(parsed)
|
||||
const subjects =
|
||||
this.canonicalSubjectsByPane.get(paneKey) ??
|
||||
new Map<string, AgentStatusStructuredSessionSubject>()
|
||||
subjects.set(key, parsed)
|
||||
this.canonicalSubjectsByPane.set(paneKey, subjects)
|
||||
if (!this.canonicalListingOrder.has(key)) {
|
||||
this.canonicalListingOrder.set(key, this.nextStatusListingOrder())
|
||||
}
|
||||
const committed = this.canonicalStatusStore.getParent(parsed)?.status
|
||||
if (!committed) {
|
||||
throw new Error('Committed structured status is missing')
|
||||
}
|
||||
const after = structuredStatusLegacyEvent(committed)
|
||||
this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after)
|
||||
this.notifyStatusChangeListeners()
|
||||
this.emitEnrichedStatus(after)
|
||||
}
|
||||
|
||||
/** The host no longer holds the session; its last projection is history the journal keeps.
|
||||
* `dropStatusEntry`, not `clearPaneState`: the renderer's own bridge still owns this pane key,
|
||||
* so a pane-status-clear would make main a second writer for it. */
|
||||
dropStructuredStatus(sessionId: string): void {
|
||||
this.dropStatusEntry(structuredStatusPaneKey(sessionId), { preserveResumeIdentity: false })
|
||||
/** Pane cleanup never resolves a canonical subject; only its owning feed can forget this row. */
|
||||
dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void {
|
||||
const parsed = parseAgentStatusSubject(subject)
|
||||
if (!parsed || parsed.kind !== 'structured-session') {
|
||||
throw new Error('Structured status removal requires its exact owner subject')
|
||||
}
|
||||
const previous = this.canonicalStatusStore.getParent(parsed)
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
const publication = this.canonicalStatusStore.applyMutation({
|
||||
removeParent: parsed
|
||||
})
|
||||
if (!publication) {
|
||||
return
|
||||
}
|
||||
const key = serializeAgentStatusSubject(parsed)
|
||||
this.canonicalListingOrder.delete(key)
|
||||
if (previous.status) {
|
||||
const subjects = this.canonicalSubjectsByPane.get(previous.status.paneKey)
|
||||
subjects?.delete(key)
|
||||
if (subjects?.size === 0) {
|
||||
this.canonicalSubjectsByPane.delete(previous.status.paneKey)
|
||||
}
|
||||
this.commitStatusRowMutation(structuredStatusLegacyEvent(previous.status), undefined)
|
||||
this.notifyStatusChangeListeners()
|
||||
this.emitStatusDropped(previous.status.paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The DERIVED pane key the renderer publishes, never the orchestration bearer handle or the minted
|
||||
// worker pane key: both of those are credentials.
|
||||
function structuredStatusPaneKey(sessionId: string): string {
|
||||
return structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
|
||||
}
|
||||
|
||||
@@ -116,8 +116,10 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
}
|
||||
this.recordCurrentAuthorityObservation(event)
|
||||
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
|
||||
if (enriched) {
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
|
||||
}
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
@@ -212,6 +214,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.ownerStateInitialized = false
|
||||
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
|
||||
clearAllListenerCaches(this.state)
|
||||
this.resetCanonicalStatus()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.paneStatusClearListeners.clear()
|
||||
this.statusDropListeners.clear()
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
} from '../../../shared/agent-status-types'
|
||||
import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits'
|
||||
import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference'
|
||||
import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state'
|
||||
import {
|
||||
getLegacyStatusListingOrder,
|
||||
type HookListenerState
|
||||
} from '../../../shared/agent-hook-listener/listener-state'
|
||||
import type {
|
||||
AgentHookAuthorityEvidence,
|
||||
AgentHookProviderSessionIdentity,
|
||||
@@ -15,8 +18,54 @@ import type {
|
||||
} from './server-types'
|
||||
import { toAgentStatusIpcPayload } from './server-status-identity'
|
||||
import { AgentHookServerState } from './server-state'
|
||||
import { serializeAgentStatusSubject } from '../../../shared/agent-status-subject'
|
||||
import { structuredStatusLegacyEvent } from './server-structured-status-row'
|
||||
|
||||
// Why: the listing counter starts at 1, so an unassigned row must sort last — never above every ordered row.
|
||||
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
|
||||
|
||||
export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
try {
|
||||
listener(enriched)
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] enriched status listener threw', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCanonicalStatusSnapshot() {
|
||||
return this.canonicalStatusStore.getSnapshot()
|
||||
}
|
||||
|
||||
_resetCanonicalStatusForTests(): void {
|
||||
this.resetCanonicalStatus()
|
||||
}
|
||||
|
||||
private combinedStatusEntries(): EnrichedAgentHookEventPayload[] {
|
||||
const rows: { entry: EnrichedAgentHookEventPayload; order: number }[] = []
|
||||
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
|
||||
rows.push({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; shared listeners expose only the base event type.
|
||||
entry: entry as EnrichedAgentHookEventPayload,
|
||||
order: getLegacyStatusListingOrder(this.state, paneKey) ?? UNORDERED_STATUS_ROW
|
||||
})
|
||||
}
|
||||
for (const parent of this.canonicalStatusStore.getSnapshot().parents) {
|
||||
if (!parent.status) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
entry: structuredStatusLegacyEvent(parent.status),
|
||||
order:
|
||||
this.canonicalListingOrder.get(serializeAgentStatusSubject(parent.subject)) ??
|
||||
UNORDERED_STATUS_ROW
|
||||
})
|
||||
}
|
||||
return rows.sort((a, b) => a.order - b.order).map(({ entry }) => entry)
|
||||
}
|
||||
/**
|
||||
* Notified once per process when repeated hook POSTs are cut off mid-body (#11217).
|
||||
* Why: the listener fails open on every request error, so without this the only symptom is
|
||||
@@ -34,10 +83,9 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
return
|
||||
}
|
||||
// Why: replay is best-effort per pane so one throwing listener can't starve the rest.
|
||||
for (const payload of this.state.lastStatusByPaneKey.values()) {
|
||||
for (const payload of this.combinedStatusEntries()) {
|
||||
try {
|
||||
// Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
|
||||
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
|
||||
listener({ ...payload, isReplay: true })
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] replay listener threw', err)
|
||||
}
|
||||
@@ -153,9 +201,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
|
||||
* dashboard catches up on hook events that fired during startup. */
|
||||
getStatusSnapshot(): AgentStatusIpcPayload[] {
|
||||
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
|
||||
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
|
||||
)
|
||||
return this.combinedStatusEntries().map(toAgentStatusIpcPayload)
|
||||
}
|
||||
|
||||
/** Provider-session identities, including Pi's metadata-only rows. */
|
||||
@@ -164,8 +210,19 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
}
|
||||
|
||||
getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] {
|
||||
const entry = this.state.lastStatusByPaneKey.get(paneKey)
|
||||
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
|
||||
const legacy = this.state.lastStatusByPaneKey.get(paneKey)
|
||||
if (legacy) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type.
|
||||
return [toAgentStatusIpcPayload(legacy as EnrichedAgentHookEventPayload)]
|
||||
}
|
||||
const rows: AgentStatusIpcPayload[] = []
|
||||
for (const subject of this.canonicalSubjectsByPane.get(paneKey)?.values() ?? []) {
|
||||
const status = this.canonicalStatusStore.getParent(subject)?.status
|
||||
if (status) {
|
||||
rows.push(status)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
|
||||
@@ -184,8 +241,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
} {
|
||||
const statuses: AgentHookStatusChangeEntry[] = []
|
||||
const providerSessions: AgentHookProviderSessionIdentity[] = []
|
||||
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
|
||||
const enriched = entry as EnrichedAgentHookEventPayload
|
||||
for (const enriched of this.combinedStatusEntries()) {
|
||||
const paneKey = enriched.paneKey
|
||||
if (enriched.providerSession) {
|
||||
providerSessions.push({
|
||||
paneKey,
|
||||
@@ -201,7 +258,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
paneKey,
|
||||
state: enriched.payload.state,
|
||||
receivedAt: enriched.receivedAt,
|
||||
observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey)
|
||||
observedInCurrentRuntime:
|
||||
Boolean(enriched.structuredHost) || this.runtimeObservedStatusPaneKeys.has(paneKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
|
||||
import {
|
||||
createHookListenerState,
|
||||
canAdmitLegacyAgentStatusEntry,
|
||||
type HookListenerState
|
||||
} from '../../../shared/agent-hook-listener/listener-state'
|
||||
import {
|
||||
@@ -21,6 +22,9 @@ import type { AgentHookSource } from '../../../shared/agent-hook-relay'
|
||||
import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types'
|
||||
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
|
||||
import type { SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { createAgentStatusStore, type AgentStatusStore } from '../../../shared/agent-status-store'
|
||||
import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter'
|
||||
import type { AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject'
|
||||
import type {
|
||||
AgentHookAuthorityEvidence,
|
||||
AgentHookProviderSessionIdentity,
|
||||
@@ -45,6 +49,38 @@ import type {
|
||||
|
||||
/** Shared mutable state for the layered hook-server implementation. */
|
||||
export abstract class AgentHookServerState {
|
||||
protected canWriteLegacyStatusRow(entry: AgentHookEventPayload): boolean {
|
||||
return canAdmitLegacyAgentStatusEntry(
|
||||
this.state,
|
||||
'main-status-update',
|
||||
entry,
|
||||
AGENT_STATUS_2A_CURRENT_PRODUCER_MODE
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the epoch is minted on first canonical use, so constructing the server — which happens at
|
||||
// import time for the module singleton — owes nothing to a live crypto implementation.
|
||||
private canonicalStatusStoreInstance: AgentStatusStore | null = null
|
||||
protected get canonicalStatusStore(): AgentStatusStore {
|
||||
this.canonicalStatusStoreInstance ??= createAgentStatusStore({
|
||||
epoch: randomUUID(),
|
||||
mode: 'authority'
|
||||
})
|
||||
return this.canonicalStatusStoreInstance
|
||||
}
|
||||
protected readonly canonicalListingOrder = new Map<string, number>()
|
||||
protected readonly canonicalSubjectsByPane = new Map<
|
||||
string,
|
||||
Map<string, AgentStatusStructuredSessionSubject>
|
||||
>()
|
||||
private statusListingOrder = 0
|
||||
protected nextStatusListingOrder = (): number => ++this.statusListingOrder
|
||||
|
||||
protected resetCanonicalStatus(): void {
|
||||
this.canonicalStatusStoreInstance = null
|
||||
this.canonicalListingOrder.clear()
|
||||
this.canonicalSubjectsByPane.clear()
|
||||
}
|
||||
protected server: ReturnType<typeof createServer> | null = null
|
||||
protected port = 0
|
||||
protected token = ''
|
||||
@@ -73,7 +109,10 @@ export abstract class AgentHookServerState {
|
||||
protected endpointFilePathCache: string | null = null
|
||||
protected endpointFileWritten = false
|
||||
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
|
||||
protected state: HookListenerState = createHookListenerState()
|
||||
protected state: HookListenerState = createHookListenerState({
|
||||
nextListingOrder: this.nextStatusListingOrder,
|
||||
isCanonicalPaneKey: (paneKey) => this.canonicalSubjectsByPane.has(paneKey)
|
||||
})
|
||||
protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null =
|
||||
null
|
||||
protected transportInterference = createHookTransportInterferenceTracker(
|
||||
@@ -169,7 +208,7 @@ export abstract class AgentHookServerState {
|
||||
origin?: AgentStatusObservationOrigin,
|
||||
observedAt?: number,
|
||||
mutationBefore?: EnrichedAgentHookEventPayload
|
||||
): EnrichedAgentHookEventPayload
|
||||
): EnrichedAgentHookEventPayload | undefined
|
||||
protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void
|
||||
protected abstract clearAssistantMessageRetry(paneKey: string): void
|
||||
protected abstract clearCodexSubagentPoll(paneKey: string): void
|
||||
|
||||
@@ -111,6 +111,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
|
||||
...(payload.subagents ? { subagents: payload.subagents } : {})
|
||||
}
|
||||
})
|
||||
if (!inferred) {
|
||||
return false
|
||||
}
|
||||
console.debug('[agent-hooks] inferred interrupted agent status', {
|
||||
paneKey: inferred.paneKey,
|
||||
agentType,
|
||||
@@ -172,6 +175,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
|
||||
...(payload.subagents ? { subagents: payload.subagents } : {})
|
||||
}
|
||||
})
|
||||
if (!inferred) {
|
||||
return false
|
||||
}
|
||||
console.debug('[agent-hooks] inferred resolved question status', {
|
||||
paneKey: inferred.paneKey,
|
||||
state: inferred.payload.state
|
||||
|
||||
@@ -77,7 +77,9 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus
|
||||
const subagentsChanged =
|
||||
JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents)
|
||||
const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original
|
||||
this.scheduleCodexSubagentPoll(source, body, next)
|
||||
if (next) {
|
||||
this.scheduleCodexSubagentPoll(source, body, next)
|
||||
}
|
||||
}
|
||||
|
||||
protected scheduleAssistantMessageRetry(
|
||||
|
||||
@@ -29,7 +29,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
origin: AgentStatusObservationOrigin = 'hook',
|
||||
observedAt?: number,
|
||||
mutationBefore?: EnrichedAgentHookEventPayload
|
||||
): EnrichedAgentHookEventPayload {
|
||||
): EnrichedAgentHookEventPayload | undefined {
|
||||
if (!this.canWriteLegacyStatusRow(payload)) {
|
||||
return undefined
|
||||
}
|
||||
if (payload.hookEventName === 'UserPromptSubmit') {
|
||||
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
|
||||
@@ -72,7 +75,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
}
|
||||
this.clearAssistantMessageRetry(enriched.paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
|
||||
this.writeLegacyStatusRow(enriched)
|
||||
if (!this.writeLegacyStatusRow(enriched)) {
|
||||
return undefined
|
||||
}
|
||||
this.commitStatusRowMutation(rowBefore, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
@@ -125,7 +130,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
if (boundaryReconciledPrevious !== previous) {
|
||||
previous = boundaryReconciledPrevious
|
||||
if (previous) {
|
||||
this.writeLegacyStatusRow(previous)
|
||||
if (!this.writeLegacyStatusRow(previous)) {
|
||||
return undefined
|
||||
}
|
||||
this.scheduleStatusPersist()
|
||||
}
|
||||
}
|
||||
@@ -224,7 +231,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
} else {
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
}
|
||||
this.writeLegacyStatusRow(enriched)
|
||||
if (!this.writeLegacyStatusRow(enriched)) {
|
||||
return undefined
|
||||
}
|
||||
this.commitStatusRowMutation(rowBefore, enriched)
|
||||
// Why skipped for structured rows: the serializer drops them, so the whole walk and stringify
|
||||
// can only ever reproduce the last file — once per debounce window for a streaming chat.
|
||||
@@ -241,6 +250,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
mutationBefore?: EnrichedAgentHookEventPayload,
|
||||
emitEnrichedStatus = false
|
||||
): void {
|
||||
if (!this.canWriteLegacyStatusRow(previous)) {
|
||||
return
|
||||
}
|
||||
const connectionClearWatermark = previous.connectionId
|
||||
? this.connectionTimestampWatermarkById.get(previous.connectionId)
|
||||
: undefined
|
||||
@@ -266,7 +278,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
}
|
||||
const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey)
|
||||
this.writeLegacyStatusRow(refreshed)
|
||||
if (!this.writeLegacyStatusRow(refreshed)) {
|
||||
return
|
||||
}
|
||||
this.commitStatusRowMutation(mutationBefore ?? previous, refreshed)
|
||||
this.scheduleStatusPersist()
|
||||
// A dismissed row may retain only provider resume identity. Its preserved payload can still
|
||||
@@ -291,21 +305,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
|
||||
}
|
||||
}
|
||||
|
||||
// Why: every status emit must reach plugins too, so a new early-return path
|
||||
// upstream cannot silently leave the plugin tap behind the main-window fanout.
|
||||
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
try {
|
||||
listener(enriched)
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] enriched status listener threw', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): void {
|
||||
admitLegacyAgentStatus(
|
||||
private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): boolean {
|
||||
return admitLegacyAgentStatus(
|
||||
this.state,
|
||||
'main-status-update',
|
||||
entry,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
pickParsedAgentStatusPayload,
|
||||
type AgentStatusIpcPayload
|
||||
} from '../../../shared/agent-status-types'
|
||||
import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
|
||||
/** Canonical rows supply legacy fanout without retaining a writable pane copy. */
|
||||
export function structuredStatusLegacyEvent(
|
||||
row: AgentStatusIpcPayload
|
||||
): EnrichedAgentHookEventPayload {
|
||||
return {
|
||||
paneKey: row.paneKey,
|
||||
tabId: row.tabId,
|
||||
worktreeId: row.worktreeId,
|
||||
connectionId: row.connectionId,
|
||||
receivedAt: row.receivedAt,
|
||||
stateStartedAt: row.stateStartedAt,
|
||||
evidenceObservedAt: row.evidenceObservedAt,
|
||||
structuredHost: row.structuredHost,
|
||||
...(row.providerSession ? { providerSession: row.providerSession } : {}),
|
||||
...(row.observation ? { observation: row.observation } : {}),
|
||||
payload: pickParsedAgentStatusPayload(row)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@
|
||||
* `agent.launch` alone. Orchestration dispatch, mobile create, CLI create and the desktop agent
|
||||
* tab each still start agents their own way; moving them here is later stack work.
|
||||
*
|
||||
* The mode decision is duplicated rather than shared: `agent-launch-mode` is a surface-neutral
|
||||
* second copy of orchestration's `orchestration-worker-start-mode`, which is unchanged and still
|
||||
* the one orchestration uses, with nothing enforcing agreement between them. That cutover is later
|
||||
* stack work too. What this module adds is the *sequencing*, and the sequencing is where the bug
|
||||
* The mode decision is shared, not copied: `agent-launch-mode` owns it, and
|
||||
* `orchestration-worker-start-mode` is a thin adapter over it supplying orchestration's receipt
|
||||
* vocabulary. What this module adds is the *sequencing*, and the sequencing is where the bug
|
||||
* was:
|
||||
*
|
||||
* create the worktree agent-first -> its startup terminal IS the agent
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type StructuredNativeChatBlocker
|
||||
} from '../../shared/structured-native-chat-launch-route'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { hasExplicitTuiLaunchCustomization } from '../../shared/tui-agent-launch-customization'
|
||||
import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
export type AgentLaunchMode = 'structured' | 'terminal'
|
||||
@@ -36,7 +36,7 @@ export type AgentLaunchModeReason =
|
||||
| 'remote_execution_host'
|
||||
| 'reused_terminal'
|
||||
| 'agent_without_structured_session'
|
||||
| 'tui_launch_customization'
|
||||
| 'tui_launch_command'
|
||||
| 'structured_sessions_unavailable'
|
||||
| 'structured_support_unknown'
|
||||
| 'wsl_execution_runtime'
|
||||
@@ -71,8 +71,7 @@ export const DEFAULT_LAUNCH_VOCABULARY: AgentLaunchModeVocabulary = {
|
||||
}
|
||||
|
||||
export type AgentLaunchModeSettings = Partial<
|
||||
NativeChatDefaultSettings &
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
|
||||
NativeChatDefaultSettings & Pick<GlobalSettings, 'agentCmdOverrides'>
|
||||
>
|
||||
|
||||
/** The placement facts the decision reads. `worktree`, `model` and `effort` are deliberately not
|
||||
@@ -89,8 +88,7 @@ const DOWNGRADE_DETAIL: Record<Exclude<AgentLaunchModeReason, 'user_default'>, s
|
||||
remote_execution_host: 'this launch runs on a remote execution host',
|
||||
reused_terminal: 'it reuses a running terminal agent',
|
||||
agent_without_structured_session: 'this agent has no structured session',
|
||||
tui_launch_customization:
|
||||
'this agent has a custom launch command, arguments or environment that only a terminal applies',
|
||||
tui_launch_command: 'this agent has a custom launch command that only a terminal runs',
|
||||
structured_sessions_unavailable: 'this runtime does not support structured agent sessions',
|
||||
structured_support_unknown: 'the execution host has not established structured session support',
|
||||
wsl_execution_runtime: 'this workspace runs under WSL',
|
||||
@@ -105,7 +103,7 @@ const BLOCKER_REASON: Record<
|
||||
'reused-terminal': 'reused_terminal',
|
||||
'agent-without-structured-session': 'agent_without_structured_session',
|
||||
'floating-workspace': 'structured_unsupported_on_host',
|
||||
'tui-launch-customization': 'tui_launch_customization',
|
||||
'tui-launch-command': 'tui_launch_command',
|
||||
'remote-execution-host': 'remote_execution_host',
|
||||
'project-runtime': 'wsl_execution_runtime',
|
||||
'runtime-capability': 'structured_sessions_unavailable',
|
||||
@@ -151,7 +149,7 @@ export function decideAgentLaunchMode(args: {
|
||||
// A resolved managed worktree or folder workspace is never a floating terminal. WSL is left to
|
||||
// the executing host's own create-support probe, which reads the resolved workspace rather
|
||||
// than guessing from a client-side project runtime.
|
||||
requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, agent)
|
||||
requiresTuiLaunchCommand: hasExplicitTuiLaunchCommand(settings, agent)
|
||||
})
|
||||
if (!support.supported) {
|
||||
return downgraded(BLOCKER_REASON[support.blocker], vocabulary)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
|
||||
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
|
||||
import { retireDeletedSessionSearchSources } from './session-search-deleted-sources'
|
||||
import {
|
||||
@@ -280,22 +279,6 @@ it('retires a synthetic row when the container it came from is gone', async () =
|
||||
})
|
||||
})
|
||||
|
||||
// Nothing in this PR can hold a synthetic row: the index pass refuses a source
|
||||
// whose parser decodes its messages where the message channel cannot reach
|
||||
// them, and OpenCode's SQLite sessions are read on a worker thread. The rule
|
||||
// above is the guard for the day that changes -- without it the walk would read
|
||||
// `<db>#<id>` as a filename and retire every such row the moment it appeared.
|
||||
it('does not index a source whose messages the channel cannot reach', () => {
|
||||
const db = join(harness.root, 'opencode.db')
|
||||
expect(
|
||||
parserPublishesMessages({
|
||||
agent: 'opencode',
|
||||
codexHome: null,
|
||||
file: { path: `${db}#session-1`, mtimeMs: 1, modifiedAt: '', sizeBytes: 0 }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
// Round 12, F1. The cap counts directories because that is what costs: rows
|
||||
// sharing one are a single read and then map lookups.
|
||||
it('caps the directories one pass reads, not the rows it answers', async () => {
|
||||
|
||||
@@ -66,6 +66,18 @@ describe('the route ladder tries phrase, then AND, then repair, then OR', () =>
|
||||
expect(ids(result).sort()).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('keeps the stop words a repaired prose phrase was typed with', async () => {
|
||||
const { db, engine } = await open('ss-engine-typo-phrase')
|
||||
// Two copies, so the repair only suggests a term the index really holds.
|
||||
addSyntheticSession(db, { id: 1, text: 'relay is dropping frames' })
|
||||
addSyntheticSession(db, { id: 2, text: 'dropping frames again here' })
|
||||
// Repairing the body alone would re-plan `relay dropping frames`, which no
|
||||
// phrase in the index can match, and the answer would fall to AND.
|
||||
const result = engine.search({ query: 'relay is droppng frames' })
|
||||
expect(result.planner.route).toBe('typo+phrase')
|
||||
expect(ids(result)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('keeps every term a repaired literal was typed with', async () => {
|
||||
const { db, engine } = await open('ss-engine-typo-literal')
|
||||
addSyntheticSession(db, { id: 1, text: 'parseJson the data' })
|
||||
@@ -184,8 +196,8 @@ describe('the conversation scope is a column filter, and it binds the whole quer
|
||||
(db.prepare('SELECT max(id) AS id FROM messages').get() as { id: number }).id
|
||||
)
|
||||
const plan = planSessionSearchQuery('harbor')
|
||||
expect(sessionSearchSnippet(db, 'conversation', rowid, plan)).toEqual(EMPTY_SNIPPET)
|
||||
expect(sessionSearchSnippet(db, 'all', rowid, plan).text).toContain('output')
|
||||
expect(sessionSearchSnippet(db, 'conversation', rowid, plan, 'or')).toEqual(EMPTY_SNIPPET)
|
||||
expect(sessionSearchSnippet(db, 'all', rowid, plan, 'or').text).toContain('output')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -471,3 +483,59 @@ it.each(['repo:target', 'path:/work/target'])(
|
||||
expect(result.truncated.candidates).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
describe('a sentence pasted out of a transcript is found behind a full candidate set', () => {
|
||||
// The words of an ordinary sentence are common, so over OR the candidate
|
||||
// limit fills with whatever is recent and the old session holding the
|
||||
// sentence never reaches ranking.
|
||||
const sentence = 'The sol review says the PR is not quite merge-ready yet'
|
||||
|
||||
async function pasted(sessionCandidateLimit = 600): Promise<SessionSearchHarness> {
|
||||
const opened = await open('ss-engine-pasted-sentence', { sessionCandidateLimit })
|
||||
addSyntheticSession(opened.db, {
|
||||
id: 1,
|
||||
text: `${sentence}, but not because of the implementation.`,
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
for (let id = 2; id <= sessionCandidateLimit + 50; id++) {
|
||||
addSyntheticSession(opened.db, {
|
||||
id,
|
||||
text: 'the review says the implementation is not quite there yet',
|
||||
updatedAt: '2026-09-09T00:00:00.000Z'
|
||||
})
|
||||
}
|
||||
return opened
|
||||
}
|
||||
|
||||
it('returns the exact sentence first, over the phrase route', async () => {
|
||||
const { engine } = await pasted()
|
||||
const result = engine.search({ query: sentence })
|
||||
expect(result.planner.route).toBe('phrase')
|
||||
expect(ids(result)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('does not claim the results were limited when the phrase rung answered', async () => {
|
||||
// The OR rung would have filled the candidate limit; the rung that answered
|
||||
// did not, and it is the answering rung the notice describes.
|
||||
const { engine } = await pasted()
|
||||
expect(engine.search({ query: sentence }).truncated.candidates).toBe(false)
|
||||
expect(engine.search({ query: 'the review says yet' }).truncated.candidates).toBe(true)
|
||||
})
|
||||
|
||||
it('falls to AND for prose whose words are all present but not adjacent', async () => {
|
||||
const { db, engine } = await open('ss-engine-prose-and')
|
||||
addSyntheticSession(db, {
|
||||
id: 1,
|
||||
text: 'yet quite merge-ready the PR is not what sol says a review of it'
|
||||
})
|
||||
const result = engine.search({ query: sentence })
|
||||
expect(result.planner.route).toBe('and')
|
||||
expect(ids(result)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('still sends a single prose word straight to OR', async () => {
|
||||
const { db, engine } = await open('ss-engine-prose-one-word')
|
||||
addSyntheticSession(db, { id: 1, text: 'relay' })
|
||||
expect(engine.search({ query: 'relay' }).planner.route).toBe('or')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -212,7 +212,7 @@ export class SessionSearchEngine {
|
||||
const { session, message } = entry
|
||||
const snippet =
|
||||
message && retrieved
|
||||
? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan)
|
||||
? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan, retrieved.route)
|
||||
: EMPTY_SNIPPET
|
||||
return {
|
||||
...sessionFields(session),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
|
||||
import {
|
||||
registerTranscriptConsumer,
|
||||
type TranscriptConsumer,
|
||||
@@ -7,7 +6,6 @@ import {
|
||||
type TranscriptReadOutcome,
|
||||
type TranscriptReadStart
|
||||
} from '../ai-vault/session-transcript-consumers'
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
import { fileIdentity } from './session-search-file-cursor'
|
||||
import type { SessionSearchFileWrite } from './session-search-index-writer'
|
||||
import type { SessionSearchStore } from './session-search-store'
|
||||
@@ -31,10 +29,6 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
|
||||
|
||||
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
|
||||
const { candidate } = start
|
||||
if (!parserPublishesMessages(candidate)) {
|
||||
this.noteUnreachableParser(candidate)
|
||||
return null
|
||||
}
|
||||
if (start.mode === 'append') {
|
||||
const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file))
|
||||
if (!cursor || cursor.byteOffset !== start.previousByteOffset) {
|
||||
@@ -60,35 +54,6 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
|
||||
}
|
||||
return new SessionSearchReadConsumer(this.store, start, write)
|
||||
}
|
||||
|
||||
/**
|
||||
* A source no read can ever index, recorded as one this index has seen.
|
||||
*
|
||||
* A parser that decodes where the message channel cannot reach it -- OpenCode's
|
||||
* SQLite sessions today -- publishes nothing, so no read of it will ever
|
||||
* commit a row. Leaving the file table silent about it is not free: the next
|
||||
* pass sees a path the index holds nothing for, asks for a read, and asking
|
||||
* over a warm cache drops the session list's own resume point. The sidebar's
|
||||
* fold is thrown away and the whole database is decoded again, on every pass,
|
||||
* for ever.
|
||||
*
|
||||
* The row written is the shape the store already has for a read that went
|
||||
* through and decoded no session: cursor at the file's size, no session row.
|
||||
* The decide step then skips it until its stat moves, and the retirement walk
|
||||
* retires it like any other row when it goes.
|
||||
*/
|
||||
private noteUnreachableParser(candidate: SessionFileCandidate): void {
|
||||
const write = this.store.beginWrite(candidate, 'replace', 0)
|
||||
const committed =
|
||||
write?.commit({
|
||||
session: null,
|
||||
byteOffset: candidate.file.sizeBytes ?? 0,
|
||||
incomplete: false
|
||||
}) === true
|
||||
if (committed) {
|
||||
this.store.writeCommitted(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SessionSearchReadConsumer implements TranscriptReadConsumer {
|
||||
|
||||
@@ -98,25 +98,42 @@ it('resumes into a grown transcript instead of re-reading it whole', async () =>
|
||||
|
||||
// Nothing is recorded about what a deadline cut off, because being owed is a
|
||||
// fact about the row: the file is read on the next pass for the same reason it
|
||||
// was owed on this one.
|
||||
// was owed on this one. The one thing handed back is how many there were, since
|
||||
// a candidate with no row yet is a backlog no query can see.
|
||||
it('leaves what it ran out of time for owed, with nothing written down', async () => {
|
||||
const all = await candidates()
|
||||
const cut = await runSessionSearchIndexPass(store, all, { rows: rows(), overdue: () => true })
|
||||
|
||||
expect(cut.outOfTime).toBe(true)
|
||||
expect(cut).toMatchObject({ outOfTime: true, left: 1 })
|
||||
expect(store.files()).toHaveLength(1)
|
||||
const second = await passOverAll()
|
||||
expect(second.stats.fullParses).toBe(1)
|
||||
expect(store.files()).toHaveLength(2)
|
||||
})
|
||||
|
||||
// A deferred candidate whose row already says `due` is in `stateCounts().due`,
|
||||
// which the status adds `left` to; counting it here would report it twice.
|
||||
it('leaves a deferred candidate out of the count when its row already says due', async () => {
|
||||
await passOverAll()
|
||||
for (const row of store.files()) {
|
||||
store.setFileState(row.path, 'due')
|
||||
}
|
||||
|
||||
const cut = await runSessionSearchIndexPass(store, await candidates(), {
|
||||
rows: rows(),
|
||||
overdue: () => true
|
||||
})
|
||||
|
||||
expect(cut).toMatchObject({ outOfTime: true, left: 0 })
|
||||
})
|
||||
|
||||
// The deadline is never applied before the pass has read anything, so a single
|
||||
// transcript larger than one deadline is read alone rather than starved.
|
||||
it('reads one file even when the deadline has already expired', async () => {
|
||||
const only = (await candidates()).slice(0, 1)
|
||||
const alone = await runSessionSearchIndexPass(store, only, { rows: rows(), overdue: () => true })
|
||||
|
||||
expect(alone.outOfTime).toBe(false)
|
||||
expect(alone).toMatchObject({ outOfTime: false, left: 0 })
|
||||
expect(store.files()).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -26,21 +26,29 @@ export type SessionSearchIndexPassOptions = {
|
||||
/**
|
||||
* Reads whatever the decide step says is owed, until the deadline.
|
||||
*
|
||||
* Nothing is recorded about what it did not reach. A candidate the deadline cut
|
||||
* off is still owed on the next pass for the same reason it was owed on this
|
||||
* one — its row says so — so there is no queue to keep, nothing to bound, and
|
||||
* nothing to drop. What the reads themselves leave behind is written by the
|
||||
* index consumer onto the rows.
|
||||
* Nothing is recorded about what it did not reach beyond `left`, a count the
|
||||
* caller reports and nothing acts on. A candidate the deadline cut off is still
|
||||
* owed on the next pass for the same reason it was owed on this one — its row
|
||||
* says so — so there is no queue to keep, nothing to bound, and nothing to
|
||||
* drop. What the reads themselves leave behind is written by the index consumer
|
||||
* onto the rows.
|
||||
*
|
||||
* `left` is what makes the backlog sayable: a candidate with no row yet, or one
|
||||
* whose row does not say it is owed, is counted by no `due` query, so without
|
||||
* this the status has no way to tell an index that holds everything from one
|
||||
* that has barely started. Candidates whose row is already `due` are left out,
|
||||
* because the status adds `left` to that same count.
|
||||
*/
|
||||
export async function runSessionSearchIndexPass(
|
||||
store: SessionSearchStore,
|
||||
candidates: readonly SessionFileCandidate[],
|
||||
options: SessionSearchIndexPassOptions
|
||||
): Promise<{ stats: SessionParseStats; outOfTime: boolean }> {
|
||||
): Promise<{ stats: SessionParseStats; outOfTime: boolean; left: number }> {
|
||||
const stats = createSessionParseStats()
|
||||
const cutoffMs = store.retentionCutoff
|
||||
let read = 0
|
||||
let outOfTime = false
|
||||
let left = 0
|
||||
for (const candidate of candidates) {
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
const path = candidate.file.path
|
||||
@@ -61,6 +69,11 @@ export async function runSessionSearchIndexPass(
|
||||
// count of what a pass left is worth more than the microseconds.
|
||||
outOfTime ||= read > 0 && options.overdue?.() === true
|
||||
if (outOfTime) {
|
||||
// A `due` row is already in `stateCounts().due`, which the status adds
|
||||
// this to; counting it here would report the same file twice.
|
||||
if (row?.state !== 'due') {
|
||||
left += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The clock the deadline reads is one the owner may close behind: the read
|
||||
@@ -80,5 +93,5 @@ export async function runSessionSearchIndexPass(
|
||||
)
|
||||
}
|
||||
}
|
||||
return { stats, outOfTime }
|
||||
return { stats, outOfTime, left }
|
||||
}
|
||||
|
||||
@@ -329,7 +329,9 @@ it('reads what one pass has time for and finishes the rest on the next', async (
|
||||
)
|
||||
}
|
||||
await indexer?.reconcile()
|
||||
expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0 })
|
||||
// Two of the four went unread, and neither has a row, so the count it hands
|
||||
// back is the only thing that can say the index is not done.
|
||||
expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 2, phase: 'indexing' })
|
||||
|
||||
await indexer?.reconcile()
|
||||
expect(sessionsMatching('deadlined')).toHaveLength(4)
|
||||
@@ -926,14 +928,24 @@ it('stops the opening sweep at its deadline and drains the rest over the passes
|
||||
await writeClaudeTranscript(transcriptPath(session), [`backlogged session ${index}`], session)
|
||||
}
|
||||
await newIndexer(readsPerPass(2)).start()
|
||||
expect(indexer?.status().filesIndexed).toBe(2)
|
||||
// A sweep that ran out of time did not sweep the machine: it says so rather
|
||||
// than stamping itself complete and reporting the three it never opened as
|
||||
// nothing at all.
|
||||
expect(indexer?.status()).toMatchObject({
|
||||
filesIndexed: 2,
|
||||
filesDue: 3,
|
||||
phase: 'indexing',
|
||||
lastSweepCompletedAt: null
|
||||
})
|
||||
|
||||
await nextCycle()
|
||||
expect(indexer?.status().filesIndexed).toBe(4)
|
||||
expect(indexer?.status()).toMatchObject({ filesIndexed: 4, filesDue: 1, phase: 'indexing' })
|
||||
expect(indexer?.status().lastSweepCompletedAt).toBeNull()
|
||||
|
||||
await nextCycle()
|
||||
expect(sessionsMatching('backlogged')).toHaveLength(5)
|
||||
expect(indexer?.status()).toMatchObject({ filesIndexed: 5, filesDue: 0 })
|
||||
expect(indexer?.status()).toMatchObject({ filesIndexed: 5, filesDue: 0, phase: 'current' })
|
||||
expect(indexer?.status().lastSweepCompletedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
// The sweep cadence, with nobody asking for it: a file outside the recency
|
||||
|
||||
@@ -32,7 +32,11 @@ export type SessionSearchIndexStatus = {
|
||||
phase: SessionSearchIndexPhase
|
||||
/** Rows whose content matches the file at the stat the row records. */
|
||||
filesIndexed: number
|
||||
/** Rows owed a whole read: a declined append, or a window that widened. */
|
||||
/**
|
||||
* Files owed a read: rows the index holds and must re-read (a declined
|
||||
* append, a window that widened), plus candidates the last pass ran out of
|
||||
* time for, which have no row to be counted by.
|
||||
*/
|
||||
filesDue: number
|
||||
/** Rows whose last read did not commit. */
|
||||
filesFailed: number
|
||||
@@ -40,6 +44,8 @@ export type SessionSearchIndexStatus = {
|
||||
lastReconcileAt: number | null
|
||||
/** When a whole-machine sweep last finished; null until one has. */
|
||||
lastSweepCompletedAt: number | null
|
||||
/** Indexed sessions per agent; an agent with files and none is unsearchable. */
|
||||
sessionsByAgent: Record<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +70,10 @@ export type SessionSearchIndexStatus = {
|
||||
* `session-search-deleted-sources.ts`.
|
||||
* - `cyclesSinceSweep` and `sweepNext`, which are about the timer rather than
|
||||
* about any file, and mean nothing to a second process.
|
||||
* - `degradedRoots`, `lastReconcileAt` and `lastSweepCompletedAt`: what the last
|
||||
* pass observed, held so `status()` can answer between passes.
|
||||
* - `degradedRoots`, `lastReconcileAt`, `lastSweepCompletedAt` and `left`: what
|
||||
* the last pass observed, held so `status()` can answer between passes.
|
||||
* `left` cannot be a row: a candidate the deadline never reached has no row
|
||||
* yet, which is exactly why no query can see the backlog.
|
||||
* - `lastCounts`, the one cached query result, read only after `close()` so that
|
||||
* describing what happened does not reopen a handle the owner has finished
|
||||
* with. While the indexer is open every call re-queries.
|
||||
@@ -99,6 +107,8 @@ export class SessionSearchIndexer {
|
||||
private degradedRoots: SessionSearchDegradedRoot[] = []
|
||||
private lastReconcileAt: number | null = null
|
||||
private lastSweepCompletedAt: number | null = null
|
||||
/** Candidates the last completed pass was owed and did not read. */
|
||||
private left = 0
|
||||
private lastCounts: SessionSearchStateCounts | null = null
|
||||
private cyclesSinceSweep = 0
|
||||
private sweepNext = false
|
||||
@@ -187,16 +197,18 @@ export class SessionSearchIndexer {
|
||||
const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? {
|
||||
current: 0,
|
||||
due: 0,
|
||||
failed: 0
|
||||
failed: 0,
|
||||
sessionsByAgent: {}
|
||||
}
|
||||
return {
|
||||
phase: this.phase(settled),
|
||||
filesIndexed: settled.current,
|
||||
filesDue: settled.due,
|
||||
filesDue: settled.due + this.left,
|
||||
filesFailed: settled.failed,
|
||||
degradedRoots: this.degradedRoots.map((root) => ({ ...root })),
|
||||
lastReconcileAt: this.lastReconcileAt,
|
||||
lastSweepCompletedAt: this.lastSweepCompletedAt
|
||||
lastSweepCompletedAt: this.lastSweepCompletedAt,
|
||||
sessionsByAgent: { ...settled.sessionsByAgent }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,11 +246,11 @@ export class SessionSearchIndexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* `current` is a claim, so it takes all three: no row owed a read, no row
|
||||
* whose last read failed, and a whole sweep that finished. `idle` is the
|
||||
* other end of it — an indexer nobody started has not promised to index
|
||||
* anything, and calling that `current` would claim an index nobody built is
|
||||
* up to date.
|
||||
* `current` is a claim, so it takes all of it: nothing owed a read by a row,
|
||||
* nothing owed a read that has no row yet, no row whose last read failed,
|
||||
* and a whole sweep that finished. `idle` is the other end of it
|
||||
* — an indexer nobody started has not promised to index anything, and calling
|
||||
* that `current` would claim an index nobody built is up to date.
|
||||
*/
|
||||
private phase(counts: SessionSearchStateCounts): SessionSearchIndexPhase {
|
||||
if (this.closed) {
|
||||
@@ -252,6 +264,10 @@ export class SessionSearchIndexer {
|
||||
if (this.degradedRoots.length > 0 || counts.failed > 0) {
|
||||
return 'degraded'
|
||||
}
|
||||
// Work the rows cannot show: a candidate the deadline cut off has no row.
|
||||
if (this.left > 0) {
|
||||
return 'indexing'
|
||||
}
|
||||
return counts.due === 0 && this.lastSweepCompletedAt !== null ? 'current' : 'indexing'
|
||||
}
|
||||
|
||||
@@ -303,12 +319,20 @@ export class SessionSearchIndexer {
|
||||
this.degradedRoots = result.degradedRoots
|
||||
this.previousRootsWithFiles = result.rootsWithFiles
|
||||
this.lastReconcileAt = this.clock.now()
|
||||
// Replaced, not accumulated: it is this pass's measure of the backlog, and
|
||||
// a pass that read everything it was owed measures zero.
|
||||
this.left = result.left
|
||||
// A backlog outside the recency window is only visible to a sweep, so a
|
||||
// pass that ran out of time asks for one. It is self-limiting: the first
|
||||
// pass that finishes its reads hands the interval back to cycles.
|
||||
this.sweepNext ||= result.outOfTime
|
||||
if (full) {
|
||||
this.lastSweepCompletedAt = this.lastReconcileAt
|
||||
// A sweep the deadline stopped with candidates still unread did not
|
||||
// sweep the machine, and stamping it would let `current` be claimed
|
||||
// over a backlog no row can account for.
|
||||
if (!result.outOfTime) {
|
||||
this.lastSweepCompletedAt = this.lastReconcileAt
|
||||
}
|
||||
this.cyclesSinceSweep = 0
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
// Only the thread hop is replaced: both implementations below are the repo's
|
||||
// own in-process readers, which the worker entry calls on the other side.
|
||||
export const openCodeParseCalls: string[] = []
|
||||
vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => {
|
||||
const list = await import('../ai-vault/session-scanner-opencode-sqlite-list')
|
||||
const parse = await import('../ai-vault/session-scanner-opencode-sqlite')
|
||||
const own = await import('./session-search-opencode-decline.test')
|
||||
return {
|
||||
resolveOpenCodeSqliteWorkerEntryPath: () => null,
|
||||
listOpenCodeSqliteSessionsViaWorker: (
|
||||
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
|
||||
) => list.listOpenCodeSqliteSessions(args),
|
||||
parseOpenCodeSqliteSessionViaWorker: (
|
||||
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
|
||||
) => {
|
||||
own.openCodeParseCalls.push(args.sessionId)
|
||||
return parse.parseOpenCodeSqliteSession(args)
|
||||
}
|
||||
}
|
||||
})
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { getSessionParseCacheEntry } from '../ai-vault/session-parse-cache-store'
|
||||
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
|
||||
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
|
||||
import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths'
|
||||
import { SessionSearchIndexer } from './session-search-indexer'
|
||||
import {
|
||||
FakeSessionSearchClock,
|
||||
openSessionSearchIndexerHarness,
|
||||
writeClaudeTranscript,
|
||||
type SessionSearchIndexerHarness
|
||||
} from './session-search-indexer-test-fixture'
|
||||
|
||||
/*
|
||||
* Round 12, F3. An OpenCode SQLite session decodes where the message channel
|
||||
* cannot reach it, so no read of one will ever commit a row. The consumer
|
||||
* declined it and wrote nothing, which left the file table silent about a
|
||||
* source discovery returns on every pass: the decide step saw a path the index
|
||||
* held nothing for, asked for a read, and asking for one over a warm cache
|
||||
* drops the session list's own resume point. Every OpenCode session was fully
|
||||
* decoded on every pass and the sidebar's fold was thrown away with it, which
|
||||
* is the cache STA-1278 and STA-1417 added.
|
||||
*/
|
||||
|
||||
const SESSION = 'ses_r12'
|
||||
const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
|
||||
let harness: SessionSearchIndexerHarness
|
||||
let clock: FakeSessionSearchClock
|
||||
let indexer: SessionSearchIndexer | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSessionParseCacheForTests()
|
||||
resetTranscriptConsumersForTests()
|
||||
clock = new FakeSessionSearchClock()
|
||||
harness = await openSessionSearchIndexerHarness('ss-opencode-decline')
|
||||
indexer = null
|
||||
openCodeParseCalls.length = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
indexer?.close()
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
await harness.cleanup()
|
||||
})
|
||||
|
||||
function writeOpenCodeDb(path: string, sessionId: string): void {
|
||||
const db = new Database(path)
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT,
|
||||
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
|
||||
summary_diffs TEXT, revert TEXT, permission TEXT,
|
||||
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
|
||||
time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT,
|
||||
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL, data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE project (
|
||||
id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT,
|
||||
icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
|
||||
time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
|
||||
time_created, time_updated, agent, model, cost, tokens_input, tokens_output,
|
||||
tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES (?, 'proj-1', NULL, 'slug-1', '/tmp/opencode', 'OpenCode title', '1.0.0',
|
||||
?, ?, 'build', '{"id":"glm"}', 0, 1, 1, 0, 0, 0)`
|
||||
).run(sessionId, 1_740_000_000_000, 1_740_000_100_000)
|
||||
db.prepare(
|
||||
`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
'msg-1',
|
||||
sessionId,
|
||||
1_740_000_000_000,
|
||||
1_740_000_000_000,
|
||||
JSON.stringify({ role: 'user', time: { created: 1_740_000_000_000 } })
|
||||
)
|
||||
db.prepare(
|
||||
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
'part-1',
|
||||
'msg-1',
|
||||
sessionId,
|
||||
1_740_000_000_000,
|
||||
1_740_000_000_000,
|
||||
JSON.stringify({ type: 'text', text: 'hello opencode' })
|
||||
)
|
||||
db.prepare(
|
||||
`INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
|
||||
VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')`
|
||||
).run(1_740_000_000_000, 1_740_000_000_000)
|
||||
db.close()
|
||||
}
|
||||
|
||||
it('reads an OpenCode session once, not on every pass', async () => {
|
||||
const dbPath = join(harness.root, 'opencode-db', 'opencode.db')
|
||||
mkdirSync(join(harness.root, 'opencode-db'), { recursive: true })
|
||||
writeOpenCodeDb(dbPath, SESSION)
|
||||
const claudePath = join(harness.claudeProjectDir, 'control.jsonl')
|
||||
await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION)
|
||||
|
||||
indexer = new SessionSearchIndexer({
|
||||
databasePath: harness.databasePath,
|
||||
roots: { ...harness.roots, opencodeDbPaths: [dbPath] },
|
||||
historyDays: null,
|
||||
clock,
|
||||
reconcileIntervalMs: 20_000,
|
||||
onError: () => undefined
|
||||
})
|
||||
await indexer.start()
|
||||
|
||||
const syntheticPath = buildOpenCodeSqliteCandidatePath(dbPath, SESSION)
|
||||
const openCodeAfterFirst = getSessionParseCacheEntry(syntheticPath)
|
||||
const claudeAfterFirst = getSessionParseCacheEntry(claudePath)
|
||||
|
||||
await indexer.reconcile()
|
||||
await indexer.reconcile()
|
||||
|
||||
// One decode across three passes, and the session list's cached fold for it
|
||||
// is the same object it was after the first: nothing invalidated it.
|
||||
expect(openCodeParseCalls).toHaveLength(1)
|
||||
expect(getSessionParseCacheEntry(syntheticPath)).toBe(openCodeAfterFirst)
|
||||
// The control, which the index really does hold, is untouched either way.
|
||||
expect(getSessionParseCacheEntry(claudePath)).toBe(claudeAfterFirst)
|
||||
|
||||
// What makes it skippable: a row saying the index has seen this source and
|
||||
// holds no session for it, which is the shape a read-through-with-no-session
|
||||
// already leaves.
|
||||
const rows = harness.read((db) =>
|
||||
db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all()
|
||||
) as { path: string; state: string; session_row_id: number | null }[]
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.find((row) => row.path === syntheticPath)).toMatchObject({
|
||||
state: 'current',
|
||||
session_row_id: null
|
||||
})
|
||||
expect(indexer.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' })
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
// Only the thread hop is replaced: all three implementations below are the
|
||||
// repo's own in-process readers, which the worker entry calls on the other side.
|
||||
export const openCodeReadCalls: string[] = []
|
||||
vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => {
|
||||
const list = await import('../ai-vault/session-scanner-opencode-sqlite-list')
|
||||
const parse = await import('../ai-vault/session-scanner-opencode-sqlite')
|
||||
const capture = await import('../ai-vault/session-scanner-opencode-sqlite-capture')
|
||||
const own = await import('./session-search-opencode-index.test')
|
||||
return {
|
||||
resolveOpenCodeSqliteWorkerEntryPath: () => null,
|
||||
listOpenCodeSqliteSessionsViaWorker: (
|
||||
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
|
||||
) => list.listOpenCodeSqliteSessions(args),
|
||||
parseOpenCodeSqliteSessionViaWorker: (
|
||||
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
|
||||
) => {
|
||||
own.openCodeReadCalls.push(`parse:${args.sessionId}`)
|
||||
return parse.parseOpenCodeSqliteSession(args)
|
||||
},
|
||||
captureOpenCodeSqliteSessionViaWorker: (
|
||||
args: Parameters<typeof capture.captureOpenCodeSqliteSession>[0]
|
||||
) => {
|
||||
own.openCodeReadCalls.push(`capture:${args.sessionId}`)
|
||||
return capture.captureOpenCodeSqliteSession(args)
|
||||
}
|
||||
}
|
||||
})
|
||||
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
|
||||
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
|
||||
import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths'
|
||||
import {
|
||||
appendOpenCodeSqliteTurn,
|
||||
writeOpenCodeSqliteDatabase
|
||||
} from '../ai-vault/session-scanner-opencode-sqlite-fixture'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { SessionSearchEngine } from './session-search-engine'
|
||||
import { SessionSearchIndexer } from './session-search-indexer'
|
||||
import { openSessionSearchDatabase } from './session-search-schema'
|
||||
import {
|
||||
FakeSessionSearchClock,
|
||||
openSessionSearchIndexerHarness,
|
||||
writeClaudeTranscript,
|
||||
type SessionSearchIndexerHarness
|
||||
} from './session-search-indexer-test-fixture'
|
||||
|
||||
/*
|
||||
* Nothing any OpenCode agent said used to be searchable. Its sessions live in
|
||||
* one SQLite database read on a worker thread, and the worker only ever
|
||||
* returned the newest few messages for the panel preview, so the index recorded
|
||||
* a placeholder row and moved on. This is the end-to-end proof that a sentence
|
||||
* an OpenCode assistant wrote comes back from a real search over a real index.
|
||||
*/
|
||||
|
||||
// Literal-looking on purpose: the `phrase` route is the one a user quoting a
|
||||
// remembered sentence takes, and only a literal query reaches it.
|
||||
const ANSWER = 'the quokkaTelemetry harness reindexes every shard'
|
||||
const OTHER = 'a completely unrelated conversation about typography'
|
||||
// Appears only in a tool part's output, so it separates the two scopes.
|
||||
const TOOL_ONLY = 'zarquonium'
|
||||
const TOOL_FILE = '/repo/app/src/telemetry/shard-reindex.ts'
|
||||
const SESSION = 'ses_capture'
|
||||
const SECOND_SESSION = 'ses_second'
|
||||
const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
|
||||
let harness: SessionSearchIndexerHarness
|
||||
let clock: FakeSessionSearchClock
|
||||
let indexer: SessionSearchIndexer | null = null
|
||||
let engineDbs: SyncDatabase[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSessionParseCacheForTests()
|
||||
resetTranscriptConsumersForTests()
|
||||
clock = new FakeSessionSearchClock()
|
||||
harness = await openSessionSearchIndexerHarness('ss-opencode-index')
|
||||
indexer = null
|
||||
engineDbs = []
|
||||
openCodeReadCalls.length = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
indexer?.close()
|
||||
for (const db of engineDbs) {
|
||||
db.close()
|
||||
}
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
await harness.cleanup()
|
||||
})
|
||||
|
||||
function dbPath(): string {
|
||||
return join(harness.root, 'opencode-db', 'opencode.db')
|
||||
}
|
||||
|
||||
async function startIndexer(): Promise<SessionSearchIndexer> {
|
||||
const started = new SessionSearchIndexer({
|
||||
databasePath: harness.databasePath,
|
||||
roots: { ...harness.roots, opencodeDbPaths: [dbPath()] },
|
||||
historyDays: null,
|
||||
clock,
|
||||
reconcileIntervalMs: 20_000,
|
||||
onError: (error) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
indexer = started
|
||||
await started.start()
|
||||
return started
|
||||
}
|
||||
|
||||
/** A second connection on the index file, the way the live instance pairs them. */
|
||||
function openEngine(): SessionSearchEngine {
|
||||
const db = openSessionSearchDatabase(harness.databasePath)
|
||||
engineDbs.push(db)
|
||||
return new SessionSearchEngine(db)
|
||||
}
|
||||
|
||||
function writeVault(): void {
|
||||
writeOpenCodeSqliteDatabase(dbPath(), [
|
||||
{
|
||||
id: SESSION,
|
||||
title: 'Telemetry work',
|
||||
directory: '/tmp/opencode',
|
||||
turns: [
|
||||
{ role: 'user', parts: ['how do I reindex the shards'] },
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'Checking how the shard map is built.' },
|
||||
'Here is the plan.',
|
||||
ANSWER,
|
||||
{
|
||||
type: 'tool',
|
||||
tool: 'read',
|
||||
// The camelCase spelling OpenCode writes; the shared key list
|
||||
// knows only `file_path`, so finding this proves the rename.
|
||||
input: { filePath: TOOL_FILE },
|
||||
output: `export const marker = '${TOOL_ONLY}'`
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
input: { command: 'pnpm reindex --all' },
|
||||
error: 'reindex exited with code 2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: SECOND_SESSION,
|
||||
title: 'Typography',
|
||||
directory: '/tmp/opencode-two',
|
||||
turns: [{ role: 'assistant', parts: [OTHER] }]
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
it('finds a sentence an OpenCode assistant wrote, through the real indexer', async () => {
|
||||
writeVault()
|
||||
await startIndexer()
|
||||
|
||||
const response = openEngine().search({ query: ANSWER })
|
||||
|
||||
expect(response.planner.route).toBe('phrase')
|
||||
expect(response.hits).toHaveLength(1)
|
||||
const hit = response.hits[0]
|
||||
expect(hit).toMatchObject({
|
||||
agent: 'opencode',
|
||||
sessionId: SESSION,
|
||||
cwd: '/tmp/opencode'
|
||||
})
|
||||
expect(hit?.evidence?.role).toBe('assistant')
|
||||
expect(hit?.evidence?.snippet).toContain('quokkaTelemetry')
|
||||
// The whole-session read, not the preview window: the user turn is indexed too.
|
||||
expect(openEngine().search({ query: 'reindex the shards' }).hits).toHaveLength(1)
|
||||
// And the sibling session is a session of its own, not folded into this one.
|
||||
expect(openEngine().search({ query: OTHER }).hits[0]?.sessionId).toBe(SECOND_SESSION)
|
||||
})
|
||||
|
||||
it('searches tool output under the all scope and not under conversation', async () => {
|
||||
writeVault()
|
||||
await startIndexer()
|
||||
|
||||
const all = openEngine().search({ query: TOOL_ONLY, scope: 'all' })
|
||||
expect(all.hits).toHaveLength(1)
|
||||
expect(all.hits[0]).toMatchObject({ agent: 'opencode', sessionId: SESSION })
|
||||
expect(all.hits[0]?.evidence?.role).toBe('tool')
|
||||
// Conversation is user and assistant turns only, so a token that lives in a
|
||||
// tool's output has nothing to match there.
|
||||
expect(openEngine().search({ query: TOOL_ONLY, scope: 'conversation' }).hits).toEqual([])
|
||||
})
|
||||
|
||||
it('indexes a tool call by its file argument and a failed one by its error', async () => {
|
||||
writeVault()
|
||||
await startIndexer()
|
||||
|
||||
// `filePath` renamed to the spelling the shared input-key list knows: without
|
||||
// it the call line would be the bare tool name and this would find nothing.
|
||||
expect(openEngine().search({ query: TOOL_FILE, scope: 'all' }).hits[0]?.sessionId).toBe(SESSION)
|
||||
// A call that failed carries its error where a completed one carries output.
|
||||
const failed = openEngine().search({ query: 'reindex exited with code', scope: 'all' })
|
||||
expect(failed.hits[0]?.evidence?.role).toBe('tool')
|
||||
})
|
||||
|
||||
it('folds a reasoning part into the assistant turn it belongs to', async () => {
|
||||
writeVault()
|
||||
await startIndexer()
|
||||
|
||||
const hit = openEngine().search({ query: 'checking how the shard map is built' }).hits[0]
|
||||
expect(hit?.sessionId).toBe(SESSION)
|
||||
expect(hit?.evidence?.role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('reads an OpenCode session once, not on every pass', async () => {
|
||||
writeVault()
|
||||
const claudePath = join(harness.claudeProjectDir, 'control.jsonl')
|
||||
await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION)
|
||||
const started = await startIndexer()
|
||||
|
||||
await started.reconcile()
|
||||
await started.reconcile()
|
||||
|
||||
// One capture per session across three passes; nothing re-decodes a session
|
||||
// whose `time_updated` has not moved.
|
||||
expect(openCodeReadCalls).toEqual([`capture:${SESSION}`, `capture:${SECOND_SESSION}`])
|
||||
const rows = harness.read((db) =>
|
||||
db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all()
|
||||
) as { path: string; state: string; session_row_id: number | null }[]
|
||||
expect(
|
||||
rows.find((row) => row.path === buildOpenCodeSqliteCandidatePath(dbPath(), SESSION))
|
||||
).toMatchObject({ state: 'current' })
|
||||
expect(
|
||||
rows.find((row) => row.path === buildOpenCodeSqliteCandidatePath(dbPath(), SESSION))
|
||||
?.session_row_id
|
||||
).not.toBeNull()
|
||||
expect(started.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' })
|
||||
// The count that made this bug visible: two OpenCode files, two OpenCode
|
||||
// sessions. Before the capture channel it read two files and zero sessions.
|
||||
expect(started.status().sessionsByAgent).toMatchObject({ opencode: 2, claude: 1 })
|
||||
})
|
||||
|
||||
it('re-reads a session that gained a message and replaces its rows', async () => {
|
||||
writeVault()
|
||||
const started = await startIndexer()
|
||||
expect(openEngine().search({ query: 'orthogonal vestibule' }).hits).toHaveLength(0)
|
||||
|
||||
appendOpenCodeSqliteTurn(dbPath(), SESSION, {
|
||||
role: 'assistant',
|
||||
parts: ['an orthogonal vestibule appeared']
|
||||
})
|
||||
await started.reconcile()
|
||||
|
||||
const engine = openEngine()
|
||||
expect(engine.search({ query: 'orthogonal vestibule' }).hits[0]?.sessionId).toBe(SESSION)
|
||||
// Replaced whole, not appended twice: the original turn is still one hit.
|
||||
expect(engine.search({ query: ANSWER }).hits).toHaveLength(1)
|
||||
expect(openCodeReadCalls.filter((call) => call === `capture:${SESSION}`)).toHaveLength(2)
|
||||
})
|
||||
@@ -96,7 +96,7 @@ it('snippets nothing for an orphaned row, even asked for it by rowid', async ()
|
||||
const { harness: open, rowids } = await withOrphans()
|
||||
const plan = planSessionSearchQuery('marmoset')
|
||||
for (const scope of ['all', 'conversation'] as const) {
|
||||
expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan)).toEqual({
|
||||
expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan, 'or')).toEqual({
|
||||
text: '',
|
||||
truncated: false
|
||||
})
|
||||
|
||||
@@ -75,6 +75,14 @@ export type SessionSearchPassResult = {
|
||||
* making progress only on the periodic sweep every five minutes.
|
||||
*/
|
||||
outOfTime: boolean
|
||||
/**
|
||||
* Candidates this pass decided were owed a read and did not read.
|
||||
*
|
||||
* Zero unless the deadline stopped the reads. Not a queue: it is the size of
|
||||
* the backlog at the moment the pass gave up, reported so the caller can say
|
||||
* so, and every one of them is owed again on the next pass by its row.
|
||||
*/
|
||||
left: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +121,7 @@ export async function runSessionSearchPass(
|
||||
|
||||
let completed = true
|
||||
let outOfTime = false
|
||||
let left = 0
|
||||
const rows = new Map(store.files().map((row) => [row.path, row]))
|
||||
try {
|
||||
const read = await runSessionSearchIndexPass(store, swept.candidates, {
|
||||
@@ -121,6 +130,7 @@ export async function runSessionSearchPass(
|
||||
overdue: args.overdue
|
||||
})
|
||||
outOfTime = read.outOfTime
|
||||
left = read.left
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) {
|
||||
throw error
|
||||
@@ -183,7 +193,8 @@ export async function runSessionSearchPass(
|
||||
unlistable
|
||||
),
|
||||
completed,
|
||||
outOfTime
|
||||
outOfTime,
|
||||
left
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,3 +82,28 @@ describe('FTS5 expressions quote every term', () => {
|
||||
expect(orExpression(['alpha', 'beta'])).toBe('"alpha" OR "beta"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the phrase candidate is the query as typed', () => {
|
||||
const sentence = 'The sol review says the PR is not quite merge-ready yet'
|
||||
|
||||
it('is prose, so nothing about its shape reaches the phrase route', () => {
|
||||
expect(isLiteralQuery(sentence)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the stop words the OR body drops, because the index holds them', () => {
|
||||
const plan = planSessionSearchQuery('why is the relay dropping frames')
|
||||
expect(plan.phrase).toEqual(['why', 'is', 'the', 'relay', 'dropping', 'frames'])
|
||||
expect(plan.body).toEqual(['relay', 'dropping', 'frames'])
|
||||
})
|
||||
|
||||
it('is the same list as the body for a literal, which keeps every token', () => {
|
||||
const plan = planSessionSearchQuery('the foo.ts file')
|
||||
expect(plan.phrase).toEqual(plan.body)
|
||||
})
|
||||
|
||||
it('quotes into one phrase a pasted sentence can actually match', () => {
|
||||
expect(phraseExpression(planSessionSearchQuery(sentence).phrase)).toBe(
|
||||
'"The sol review says the PR is not quite merge-ready yet"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,8 +31,15 @@ export type SessionSearchQueryPlan = {
|
||||
truncated: boolean
|
||||
/** Deduplicated index-faithful terms for the OR fallback, incl. identifier pieces. */
|
||||
terms: string[]
|
||||
/** Query-order tokens minus stop words: the phrase / AND candidate. */
|
||||
/** Query-order tokens minus stop words for prose, all of them for a literal. */
|
||||
body: string[]
|
||||
/**
|
||||
* Query-order tokens exactly as typed, stop words kept: the phrase / AND
|
||||
* candidate. A sentence pasted out of a transcript is only adjacent in the
|
||||
* index with its stop words in place, and `unicode61` indexes them, so the
|
||||
* phrase rung has to search the words the user actually typed.
|
||||
*/
|
||||
phrase: string[]
|
||||
}
|
||||
|
||||
export function isLiteralQuery(query: string): boolean {
|
||||
@@ -95,7 +102,8 @@ export function planSessionSearchQuery(
|
||||
literal,
|
||||
truncated,
|
||||
terms: [...terms, ...extra].slice(0, MAX_TERMS),
|
||||
body: body.slice(0, MAX_BODY_TERMS)
|
||||
body: body.slice(0, MAX_BODY_TERMS),
|
||||
phrase: raw
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,7 @@ export class SessionSearchRetrieval {
|
||||
}
|
||||
|
||||
/**
|
||||
* The route ladder: phrase, then AND for a literal-looking query, then typo
|
||||
* repair, then OR.
|
||||
* The route ladder: phrase, then AND, then typo repair, then OR.
|
||||
*
|
||||
* Repair runs before the OR fallback rather than after it fails. A typo next
|
||||
* to a common word would otherwise be masked: the common word alone retrieves
|
||||
@@ -84,7 +83,10 @@ export class SessionSearchRetrieval {
|
||||
let sessions: SessionRow[] = []
|
||||
const match = (expression: string): MessageRow[] => {
|
||||
const rows = this.match(expression, scope)
|
||||
incomplete ||= rows.length >= scope.candidateLimit
|
||||
// Assigned, not accumulated: only the rung whose rows are returned can
|
||||
// say whether a cap hid anything. A phrase rung that filled the limit and
|
||||
// was then discarded describes a row set the answering rung never used.
|
||||
incomplete = rows.length >= scope.candidateLimit
|
||||
sessions = this.loadSessions(
|
||||
rows.map((row) => row.session_row_id),
|
||||
scope
|
||||
@@ -92,13 +94,13 @@ export class SessionSearchRetrieval {
|
||||
const eligible = new Set(sessions.map((row) => row.id))
|
||||
return rows.filter((row) => eligible.has(row.session_row_id))
|
||||
}
|
||||
const exact = this.literal(plan, match)
|
||||
const exact = this.phraseThenAnd(plan, match)
|
||||
if (exact) {
|
||||
return { ...exact, plan, incomplete, sessions }
|
||||
}
|
||||
const repaired = this.repair(plan, scope.scope)
|
||||
const effective = repaired ?? plan
|
||||
const literal = repaired ? this.literal(repaired, match) : null
|
||||
const literal = repaired ? this.phraseThenAnd(repaired, match) : null
|
||||
const found = literal ?? {
|
||||
rows: match(orExpression(effective.terms)),
|
||||
route: 'or' as const
|
||||
@@ -178,41 +180,59 @@ export class SessionSearchRetrieval {
|
||||
): SessionSearchQueryPlan | null {
|
||||
const typoRepair = this.typoRepair
|
||||
let changed = false
|
||||
const body = plan.body.map((term) => {
|
||||
// Only the body is a candidate for a correction, but the re-plan is fed the
|
||||
// tokens as typed: re-planning the body alone would hand the phrase rung a
|
||||
// sentence with its stop words already gone, and `relay dropping frames`
|
||||
// cannot match the `relay is dropping frames` that is in the transcript.
|
||||
const repairable = new Set(plan.body.map((term) => term.toLowerCase()))
|
||||
const phrase = plan.phrase.map((token) => {
|
||||
if (!repairable.has(token.toLowerCase())) {
|
||||
return token
|
||||
}
|
||||
// Repaired inside the scope the search will run in, so a spelling only
|
||||
// tool output carries neither suppresses a repair nor becomes one.
|
||||
const fix = typoRepair.correct(term, scope)
|
||||
if (fix && fix !== term.toLowerCase()) {
|
||||
const fix = typoRepair.correct(token, scope)
|
||||
if (fix && fix !== token.toLowerCase()) {
|
||||
changed = true
|
||||
return fix
|
||||
}
|
||||
return term
|
||||
return token
|
||||
})
|
||||
// The repair changes spellings, not the query's character: the re-plan is
|
||||
// told what the original decided so a corrected literal keeps every term it
|
||||
// was typed with.
|
||||
return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null
|
||||
return changed ? planSessionSearchQuery(phrase.join(' '), plan.literal) : null
|
||||
}
|
||||
|
||||
/** Phrase, then AND, for literal-looking queries; null when neither matches. */
|
||||
private literal(
|
||||
/**
|
||||
* Phrase, then AND, over the tokens as typed; null when neither matches.
|
||||
*
|
||||
* Prose runs it too, and not only a literal-looking query. A sentence pasted
|
||||
* out of a transcript is ordinary words in order, and over OR its common
|
||||
* words fill the candidate limit with recent sessions long before the old
|
||||
* session that holds the sentence is reached, so the exact match a user can
|
||||
* see in front of them comes back missing.
|
||||
*/
|
||||
private phraseThenAnd(
|
||||
plan: SessionSearchQueryPlan,
|
||||
match: (expression: string) => MessageRow[]
|
||||
): { rows: MessageRow[]; route: 'phrase' | 'and' } | null {
|
||||
if (!plan.literal || plan.body.length === 0) {
|
||||
return null
|
||||
}
|
||||
const tokens = plan.phrase
|
||||
// A one-token literal (`resolveTerminalPath`, `src/a/b.ts`) is its own
|
||||
// phrase: the tokenizer keeps it whole, so the exact token is the cheap,
|
||||
// precise first try before the identifier pieces fan out over OR.
|
||||
const phrase = match(phraseExpression(plan.body))
|
||||
// precise first try before the identifier pieces fan out over OR. One word
|
||||
// of prose is not quoting anything, so it goes straight to OR as before.
|
||||
if (tokens.length === 0 || (tokens.length < 2 && !plan.literal)) {
|
||||
return null
|
||||
}
|
||||
const phrase = match(phraseExpression(tokens))
|
||||
if (phrase.length > 0) {
|
||||
return { rows: phrase, route: 'phrase' }
|
||||
}
|
||||
if (plan.body.length < 2) {
|
||||
if (tokens.length < 2) {
|
||||
return null
|
||||
}
|
||||
const and = match(andExpression(plan.body))
|
||||
const and = match(andExpression(tokens))
|
||||
return and.length > 0 ? { rows: and, route: 'and' } : null
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
// policy, decided where the wire is.
|
||||
|
||||
// Bump to drop and rebuild: the index is a cache over the transcripts, never a source.
|
||||
export const SESSION_SEARCH_SCHEMA_VERSION = 5
|
||||
export const SESSION_SEARCH_SCHEMA_VERSION = 6
|
||||
|
||||
// unicode61 keeps `_ . - /` inside tokens so paths and identifiers match exactly;
|
||||
// the `identifiers` column carries the split form (see session-search-identifier-split).
|
||||
|
||||
@@ -24,7 +24,7 @@ async function fixture() {
|
||||
// degradedRoots is re-stated because the contract type leaves `root` optional
|
||||
// for relay redaction, while the indexer always names the root it degraded.
|
||||
const indexer = {
|
||||
status: () => ({ ...status, degradedRoots: [] }),
|
||||
status: () => ({ ...status, degradedRoots: [], sessionsByAgent: {} }),
|
||||
reconcile: vi.fn(async () => {})
|
||||
}
|
||||
const service = createSessionSearchService({ engine: harness.engine, indexer })
|
||||
|
||||
@@ -142,3 +142,31 @@ it('does not cut a snippet at a private-use code point the transcript wrote', as
|
||||
)
|
||||
expect(snippet).toContain('qqqqq')
|
||||
})
|
||||
|
||||
it('marks a phrase hit as one run, stop words included', async () => {
|
||||
harness = await openSessionSearchHarness('ss-snippet-phrase-run')
|
||||
addSyntheticSession(harness.db, {
|
||||
id: 1,
|
||||
text: 'Agent: the code already has several fixes for blank restores, including replaying'
|
||||
})
|
||||
|
||||
const result = harness.engine.search({ query: 'the code already has several fixes' })
|
||||
expect(result.planner.route).toBe('phrase')
|
||||
expect(result.hits[0]?.evidence?.snippet).toContain(
|
||||
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}the code already has several fixes${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
|
||||
)
|
||||
})
|
||||
|
||||
it('marks every typed word of an AND hit, stop words included', async () => {
|
||||
harness = await openSessionSearchHarness('ss-snippet-and-words')
|
||||
addSyntheticSession(harness.db, { id: 1, text: 'fixes for the restore path, several of them' })
|
||||
|
||||
const result = harness.engine.search({ query: 'several fixes for the restore' })
|
||||
expect(result.planner.route).toBe('and')
|
||||
const snippet = result.hits[0]?.evidence?.snippet ?? ''
|
||||
for (const word of ['several', 'fixes', 'for', 'the', 'restore']) {
|
||||
expect(snippet).toContain(
|
||||
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}${word}${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
SESSION_SEARCH_SNIPPET_MARK_OPEN
|
||||
} from './session-search-engine-types'
|
||||
import {
|
||||
andExpression,
|
||||
orExpression,
|
||||
phraseExpression,
|
||||
scopedExpression,
|
||||
type SessionSearchQueryPlan
|
||||
} from './session-search-query-planner'
|
||||
import type { SessionSearchScope } from './session-search-engine-types'
|
||||
import type { SessionSearchRoute, SessionSearchScope } from './session-search-engine-types'
|
||||
|
||||
// What FTS5 wraps a match in before this module rewrites it to the public
|
||||
// marks. Private-use code points, and not `[[`, because two different jobs here
|
||||
@@ -35,15 +37,17 @@ export const EMPTY_SNIPPET: SessionSearchSnippet = { text: '', truncated: false
|
||||
/**
|
||||
* The window of one message that shows why it matched.
|
||||
*
|
||||
* The expression is the plan's OR form rather than the route's, so a hit found
|
||||
* through typo repair is marked with the repaired terms it was actually
|
||||
* retrieved by, and a phrase hit still marks each of its words.
|
||||
* Marked with the expression the route retrieved by, so a phrase hit is one
|
||||
* highlight over the words as typed, stop words included, and an OR hit marks
|
||||
* each term it was found through. The plan is the effective one, so a hit
|
||||
* found through typo repair is marked with the repaired terms.
|
||||
*/
|
||||
export function sessionSearchSnippet(
|
||||
db: SyncDatabase,
|
||||
scope: SessionSearchScope,
|
||||
rowid: number,
|
||||
plan: SessionSearchQueryPlan
|
||||
plan: SessionSearchQueryPlan,
|
||||
route: SessionSearchRoute
|
||||
): SessionSearchSnippet {
|
||||
// Why: the identifier shadow column is word soup; a hit that also matches in a
|
||||
// prose column should be shown from there. Column -1 (any column) is the
|
||||
@@ -83,10 +87,8 @@ export function sessionSearchSnippet(
|
||||
JOIN sessions s ON s.id = m.session_row_id
|
||||
WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)`
|
||||
)
|
||||
.get(scopedExpression(scope, orExpression(plan.terms)), rowid) as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
if (!row) {
|
||||
.get(scopedExpression(scope, routeExpression(plan, route)), rowid)
|
||||
if (!isSnippetRow(row)) {
|
||||
return EMPTY_SNIPPET
|
||||
}
|
||||
// A snippet with nothing highlighted tells the user nothing; omit it.
|
||||
@@ -103,6 +105,24 @@ export function sessionSearchSnippet(
|
||||
}
|
||||
}
|
||||
|
||||
function isSnippetRow(value: unknown): value is Record<string, string> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.values(value).every((column) => typeof column === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function routeExpression(plan: SessionSearchQueryPlan, route: SessionSearchRoute): string {
|
||||
if (route.endsWith('phrase')) {
|
||||
return phraseExpression(plan.phrase)
|
||||
}
|
||||
if (route.endsWith('and')) {
|
||||
return andExpression(plan.phrase)
|
||||
}
|
||||
return orExpression(plan.terms)
|
||||
}
|
||||
|
||||
/** One run of the snippet's own text, or one mark FTS5 put between two runs. */
|
||||
type SnippetPiece = { kind: 'text'; value: string } | { kind: 'mark'; value: string }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { asRecord } from '../ai-vault/session-scanner-record-value'
|
||||
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
|
||||
import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
|
||||
import type {
|
||||
@@ -42,7 +43,20 @@ export type SessionSearchFileRow = {
|
||||
}
|
||||
|
||||
/** How many rows are in each state; the whole of the indexer's progress report. */
|
||||
export type SessionSearchStateCounts = { current: number; due: number; failed: number }
|
||||
export type SessionSearchStateCounts = {
|
||||
current: number
|
||||
due: number
|
||||
failed: number
|
||||
/**
|
||||
* Indexed sessions per agent.
|
||||
*
|
||||
* The one number that distinguishes an agent the index has read from one it
|
||||
* has only listed: OpenCode's 606 rows in `files` with nothing in `sessions`
|
||||
* was the shape of a whole source being silently unsearchable, and no
|
||||
* file-state count could show it.
|
||||
*/
|
||||
sessionsByAgent: Record<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the index database. PR 2 scope: the write half only — the transcript
|
||||
@@ -272,13 +286,34 @@ export class SessionSearchStore {
|
||||
state: SessionSearchFileState
|
||||
n: number
|
||||
}[]
|
||||
const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0 }
|
||||
const counts: SessionSearchStateCounts = {
|
||||
current: 0,
|
||||
due: 0,
|
||||
failed: 0,
|
||||
sessionsByAgent: this.sessionsByAgent()
|
||||
}
|
||||
for (const row of rows) {
|
||||
counts[row.state] = Number(row.n)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// Grouped on `sessions_agent`, over one row per indexed session. Deliberately
|
||||
// not the message count beside it: that would scan every indexed row on a call
|
||||
// the panel polls, and it answers the same question one table later.
|
||||
private sessionsByAgent(): Record<string, number> {
|
||||
const rows = this.db.prepare('SELECT agent, count(*) AS n FROM sessions GROUP BY agent').all()
|
||||
const counts: Record<string, number> = {}
|
||||
for (const row of rows) {
|
||||
const agent = asRecord(row)?.agent
|
||||
const total = asRecord(row)?.n
|
||||
if (typeof agent === 'string' && typeof total === 'number') {
|
||||
counts[agent] = total
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a source's rows. Only a proven deletion may call this: an unreadable
|
||||
* source is `unverifiable`, not `missing`, and keeps its rows
|
||||
|
||||
@@ -6,11 +6,11 @@ import { parseClineSessionFile } from './session-scanner-cline-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import {
|
||||
looksLikeOpenCodeSqliteCandidate,
|
||||
splitOpenCodeSqliteCandidate
|
||||
} from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn'
|
||||
captureOpenCodeSqliteSessionViaWorker,
|
||||
parseOpenCodeSqliteSessionViaWorker
|
||||
} from './session-scanner-opencode-sqlite-worker-spawn'
|
||||
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
@@ -22,12 +22,27 @@ import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import type { TranscriptMessageSink } from './session-transcript-consumers'
|
||||
|
||||
/**
|
||||
* False when a parser decodes its messages somewhere the channel cannot reach.
|
||||
* OpenCode's SQLite sessions are read on a worker thread, so their messages
|
||||
* never come back over the sink and the read must not be reported as complete.
|
||||
* Read an OpenCode SQLite session on the worker thread.
|
||||
*
|
||||
* Two request kinds rather than one, chosen by whether anyone is listening: a
|
||||
* list scan wants the newest few messages for the panel preview, so asking for
|
||||
* the whole transcript would read every part of every session on every refresh.
|
||||
* A read with a sink is the search index's, and that one needs all of it.
|
||||
*/
|
||||
export function parserPublishesMessages(candidate: SessionFileCandidate): boolean {
|
||||
return candidate.agent !== 'opencode' || !looksLikeOpenCodeSqliteCandidate(candidate.file.path)
|
||||
async function readOpenCodeSqliteCandidate(
|
||||
sqliteCandidate: { dbPath: string; sessionId: string },
|
||||
platform: NodeJS.Platform,
|
||||
messages?: TranscriptMessageSink
|
||||
): Promise<AiVaultSession | null> {
|
||||
const request = { ...sqliteCandidate, platform }
|
||||
if (!messages?.active) {
|
||||
return parseOpenCodeSqliteSessionViaWorker(request)
|
||||
}
|
||||
const capture = await captureOpenCodeSqliteSessionViaWorker(request)
|
||||
for (const message of capture.messages) {
|
||||
messages.push(message)
|
||||
}
|
||||
return capture.session
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,11 +85,7 @@ export async function parseAgentSessionFile(
|
||||
// real filesystem paths and fall through to the JSON parser.
|
||||
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
|
||||
if (sqliteCandidate) {
|
||||
return parseOpenCodeSqliteSessionViaWorker({
|
||||
dbPath: sqliteCandidate.dbPath,
|
||||
sessionId: sqliteCandidate.sessionId,
|
||||
platform
|
||||
})
|
||||
return readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages)
|
||||
}
|
||||
return parseOpenCodeSessionFile(candidate.file, platform, messages)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
import {
|
||||
createCodexSessionResumeState,
|
||||
parseCodexSessionFile
|
||||
} from './session-scanner-codex-parser'
|
||||
import type { TranscriptMessage } from './session-transcript-consumers'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
@@ -16,6 +20,24 @@ function jsonLines(records: unknown[]): string {
|
||||
}
|
||||
|
||||
describe('parseCodexSessionFile', () => {
|
||||
it('publishes a paginated agent reply whose block is typed Text to transcript consumers', () => {
|
||||
const timestamp = '2026-08-10T10:00:00.000Z'
|
||||
const messages: TranscriptMessage[] = []
|
||||
const state = createCodexSessionResumeState(
|
||||
{ path: '/fixture/rollout.jsonl', mtimeMs: Date.parse(timestamp), modifiedAt: timestamp },
|
||||
null,
|
||||
{ active: true, push: (message) => messages.push(message) }
|
||||
)
|
||||
const consume = (type: string, payload: Record<string, unknown>) =>
|
||||
state.consumeLineBytes!(Buffer.from(JSON.stringify({ timestamp, type, payload })))
|
||||
consume('session_meta', { id: 'paginated-session', history_mode: 'paginated' })
|
||||
consume('event_msg', {
|
||||
type: 'item_completed',
|
||||
item: { type: 'AgentMessage', content: [{ type: 'Text', text: 'the reply' }] }
|
||||
})
|
||||
expect(messages).toEqual([{ role: 'assistant', text: 'the reply', timestamp }])
|
||||
})
|
||||
|
||||
it('uses completed user items for paginated session metadata', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-paginated-'))
|
||||
tempRoots.push(root)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { writeAntigravityScannerFixture } from './session-scanner-test-fixtures'
|
||||
import { jsonlBody, type AgentVaultRoots } from './session-scanner-vault-roots'
|
||||
|
||||
// The agents whose session is a JSON document, or a directory of them, rewritten
|
||||
// in place rather than appended to. Antigravity rides along here because its
|
||||
// fixture writer already owns the layout.
|
||||
|
||||
/**
|
||||
* Write one session per document-shaped agent.
|
||||
* @param root - The vault root, which Kimi's session index lives directly in.
|
||||
* @param roots - The scan roots to write under.
|
||||
* @param antigravitySessionId - The conversation id Antigravity resumes by.
|
||||
*/
|
||||
export async function writeDocumentAgentFixtures(
|
||||
root: string,
|
||||
roots: AgentVaultRoots,
|
||||
antigravitySessionId: string
|
||||
): Promise<void> {
|
||||
await mkdir(roots.geminiSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.geminiSessionsDir, 'gemini-session.json'),
|
||||
JSON.stringify({
|
||||
sessionId: 'gemini-session',
|
||||
startTime: '2026-05-01T10:02:00.000Z',
|
||||
lastUpdated: '2026-05-01T10:02:01.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-05-01T10:02:00.000Z',
|
||||
content: [{ text: 'Gemini title' }]
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
timestamp: '2026-05-01T10:02:01.000Z',
|
||||
model: 'gemini-2.5-pro',
|
||||
tokens: { input: 10, output: 5 }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId)
|
||||
|
||||
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
|
||||
await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'),
|
||||
JSON.stringify({
|
||||
id: 'opencode-session',
|
||||
directory: '/tmp/opencode',
|
||||
title: 'OpenCode title',
|
||||
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
summary: { title: 'OpenCode title' },
|
||||
time: { created: 1_777_634_000_000 },
|
||||
tokens: { input: 7, output: 3 }
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
|
||||
recursive: true
|
||||
})
|
||||
await writeFile(
|
||||
join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
|
||||
JSON.stringify({
|
||||
info: { id: 'grok-session', cwd: '/tmp/grok' },
|
||||
session_summary: '',
|
||||
created_at: '2026-05-01T10:04:00.000Z',
|
||||
updated_at: '2026-05-01T10:04:01.000Z',
|
||||
num_chat_messages: 2,
|
||||
current_model_id: 'grok-build',
|
||||
head_branch: 'feature/grok-vault'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(
|
||||
roots.grokSessionsDir,
|
||||
encodeURIComponent('/tmp/grok'),
|
||||
'grok-session',
|
||||
'chat_history.jsonl'
|
||||
),
|
||||
jsonlBody([
|
||||
{
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<user_info>context</user_info><user_query>Grok title</user_query>'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 'assistant', content: 'Done' }
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.hermesSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
|
||||
JSON.stringify({
|
||||
session_id: 'hermes-session',
|
||||
model: 'hermes-1',
|
||||
cwd: '/tmp/hermes',
|
||||
session_start: '2026-05-01T10:05:00.000Z',
|
||||
last_updated: '2026-05-01T10:05:01.000Z',
|
||||
messages: [{ role: 'user', content: 'Hermes title' }]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'),
|
||||
JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' })
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'),
|
||||
JSON.stringify({
|
||||
message_history: [
|
||||
{
|
||||
kind: 'request',
|
||||
timestamp: '2026-05-01T10:06:00.000Z',
|
||||
parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(roots.devinTranscriptsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.devinTranscriptsDir, 'devin-session.json'),
|
||||
JSON.stringify({
|
||||
session_id: 'devin-session',
|
||||
working_directory: '/tmp/devin',
|
||||
agent: { model_name: 'swe-1-6-fast' },
|
||||
steps: [
|
||||
{
|
||||
metadata: {
|
||||
created_at: '2026-05-01T10:10:00.000Z',
|
||||
is_user_input: true,
|
||||
metrics: { input_tokens: 1, output_tokens: 2 }
|
||||
},
|
||||
text: 'Devin vault title'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const clineSessionId = 'cline-session'
|
||||
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
|
||||
await mkdir(clineSessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.json`),
|
||||
JSON.stringify({
|
||||
session_id: clineSessionId,
|
||||
started_at: '2026-05-01T10:10:30.000Z',
|
||||
model: 'cline-model',
|
||||
cwd: '/tmp/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
updated_at: '2026-05-01T10:10:31.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
|
||||
// with the work dir resolved from the top-level session_index.jsonl.
|
||||
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
|
||||
await mkdir(join(kimiSessionDir, 'agents', 'main'), { recursive: true })
|
||||
await writeFile(
|
||||
join(kimiSessionDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
createdAt: '2026-05-01T10:11:00.000Z',
|
||||
updatedAt: '2026-05-01T10:11:05.000Z',
|
||||
title: 'Kimi vault title',
|
||||
lastPrompt: 'Kimi vault title',
|
||||
agents: { main: { type: 'main', parentAgentId: null } }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(root, 'session_index.jsonl'),
|
||||
jsonlBody([
|
||||
{ sessionId: 'session_kimi-session', sessionDir: kimiSessionDir, workDir: '/tmp/kimi' }
|
||||
])
|
||||
)
|
||||
await writeFile(
|
||||
join(kimiSessionDir, 'agents', 'main', 'wire.jsonl'),
|
||||
jsonlBody([
|
||||
{ type: 'config.update', modelAlias: 'kimi-k2.6', time: 1781853559132 },
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Kimi vault title' }],
|
||||
origin: { kind: 'user' }
|
||||
},
|
||||
time: 1781853559164
|
||||
},
|
||||
{
|
||||
type: 'context.append_loop_event',
|
||||
event: { type: 'content.part', part: { type: 'text', text: 'Kimi reply' } },
|
||||
time: 1781853559177
|
||||
},
|
||||
{ type: 'context.append_loop_event', event: { type: 'step.end' }, time: 1781853559178 },
|
||||
{
|
||||
type: 'usage.record',
|
||||
model: 'kimi-k2.6',
|
||||
usage: { inputOther: 4, output: 6, inputCacheRead: 0, inputCacheCreation: 0 },
|
||||
usageScope: 'turn',
|
||||
time: 1781853559178
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures'
|
||||
import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures'
|
||||
|
||||
// Why this is shared rather than inline in one test: it is the only place that
|
||||
// writes one transcript in every supported agent's own format. A scan test and
|
||||
// the search index's capture guard both need exactly that, and a second copy
|
||||
// would drift the moment an agent's layout changed.
|
||||
|
||||
export type EveryAgentVault = {
|
||||
roots: ReturnType<typeof isolatedScanRoots>
|
||||
/** Ids the caller asserts resume commands against. */
|
||||
antigravitySessionId: string
|
||||
/** OMP and Prime Agent resume by absolute transcript path, not by id. */
|
||||
ompSessionFile: string
|
||||
primeAgentSessionFile: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one session per supported agent under `root`, each in that agent's own
|
||||
* on-disk layout. OpenCode gets its legacy JSON layout here; its SQLite layout
|
||||
* has its own builder, because it needs a database rather than a tree.
|
||||
* @param root - An empty temporary directory to build the vault in.
|
||||
* @returns The scan roots for `root`, and the ids a caller asserts against.
|
||||
*/
|
||||
export async function writeEveryAgentVault(root: string): Promise<EveryAgentVault> {
|
||||
const roots = isolatedScanRoots(root)
|
||||
const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const { ompSessionFile, primeAgentSessionFile } = await writeLogAgentFixtures(roots)
|
||||
await writeDocumentAgentFixtures(root, roots, antigravitySessionId)
|
||||
return { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile }
|
||||
}
|
||||
@@ -27,8 +27,12 @@ const ALLOWLIST = new Set([
|
||||
// On-demand IPC readers, gated in the STA-4049 follow-up.
|
||||
'session-scanner-claude-subagents.ts',
|
||||
'session-scanner-omp-subagent-listing.ts',
|
||||
// Test-only fixture builder.
|
||||
'session-scanner-test-fixtures.ts'
|
||||
// Test-only fixture builders: they create the vault a test reads, so the
|
||||
// paths they touch are temp directories this process just made.
|
||||
'session-scanner-test-fixtures.ts',
|
||||
'session-scanner-document-agent-fixtures.ts',
|
||||
'session-scanner-log-agent-fixtures.ts',
|
||||
'session-scanner-opencode-sqlite-fixture.ts'
|
||||
])
|
||||
|
||||
const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
writeOmpScannerFixture,
|
||||
writePrimeAgentScannerFixture
|
||||
} from './session-scanner-test-fixtures'
|
||||
import { jsonlBody, type AgentVaultRoots } from './session-scanner-vault-roots'
|
||||
|
||||
// The agents whose session is an append-only JSONL log the CLI writes a record
|
||||
// at a time. Split from the document-shaped agents purely by file size; the two
|
||||
// halves are called together and neither is meaningful alone.
|
||||
|
||||
/**
|
||||
* Write one append-only transcript per log-shaped agent.
|
||||
* @param roots - The scan roots to write under.
|
||||
* @returns The transcript paths OMP and Prime Agent resume by.
|
||||
*/
|
||||
export async function writeLogAgentFixtures(
|
||||
roots: AgentVaultRoots
|
||||
): Promise<{ ompSessionFile: string; primeAgentSessionFile: string }> {
|
||||
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Claude title' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
timestamp: '2026-05-01T10:01:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'codex-session', cwd: '/tmp/codex' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-01T10:01:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Codex title' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.copilotSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.copilotSessionsDir, 'copilot-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
type: 'session.start',
|
||||
data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' },
|
||||
timestamp: '2026-05-01T10:03:00.000Z'
|
||||
},
|
||||
{
|
||||
type: 'session.info',
|
||||
data: {
|
||||
infoType: 'folder_trust',
|
||||
message: 'Folder /tmp/copilot has been added to trusted folders.'
|
||||
},
|
||||
timestamp: '2026-05-01T10:03:01.000Z'
|
||||
},
|
||||
{
|
||||
type: 'user.message',
|
||||
data: { transformedContent: 'Copilot title' },
|
||||
timestamp: '2026-05-01T10:03:02.000Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
role: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Cursor title' }] }
|
||||
},
|
||||
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'openclaw-session',
|
||||
timestamp: '2026-05-01T10:07:00.000Z',
|
||||
cwd: '/tmp/openclaw'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:07:01.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.piSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.piSessionsDir, 'pi-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'pi-session',
|
||||
timestamp: '2026-05-01T10:08:00.000Z',
|
||||
cwd: '/tmp/pi'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:08:01.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const ompSessionFile = await writeOmpScannerFixture(roots.ompSessionsDir)
|
||||
const primeAgentSessionFile = await writePrimeAgentScannerFixture(roots.primeAgentSessionsDir)
|
||||
|
||||
await mkdir(roots.droidSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.droidSessionsDir, 'droid-session.jsonl'),
|
||||
jsonlBody([
|
||||
{
|
||||
type: 'system',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:00.000Z',
|
||||
model: 'droid-model',
|
||||
cwd: '/tmp/droid'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:01.000Z',
|
||||
role: 'user',
|
||||
text: 'Droid title'
|
||||
},
|
||||
{
|
||||
type: 'completion',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:02.000Z',
|
||||
usage: { input_tokens: 2, output_tokens: 3 }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
return { ompSessionFile, primeAgentSessionFile }
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { timestampIso } from './session-scanner-accumulator'
|
||||
import { asRecord } from './session-scanner-record-value'
|
||||
import { extractPartText, readOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
|
||||
import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-schema'
|
||||
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
|
||||
import { boundedText, toolCallText } from './session-transcript-message-content'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
// Why: the session list needs the newest few messages, and the search index
|
||||
// needs every one of them. That is the only difference between this read and
|
||||
// `parseOpenCodeSqliteSession`, so the decoding is shared and only the query
|
||||
// that selects the rows differs.
|
||||
|
||||
/** The part types that carry something a person would search for. */
|
||||
const OPENCODE_CAPTURE_PART_TYPES = "('text','reasoning','tool')"
|
||||
|
||||
/**
|
||||
* How many parts one session may hold before this read gives up.
|
||||
*
|
||||
* A safety valve on memory, not a policy: the rows are materialized and then
|
||||
* posted across the worker boundary, so an unbounded session would be held
|
||||
* twice. Exceeding it throws rather than returning a prefix, because a prefix
|
||||
* committed under a complete-read cursor would leave the tail unsearchable with
|
||||
* nothing on the row to say so. A failed read is retried and surfaces; a silent
|
||||
* truncation does neither. Measured against a real 21 GB database: the busiest
|
||||
* session there holds 1,427 of these parts.
|
||||
*/
|
||||
const OPENCODE_CAPTURE_PART_LIMIT = 20_000
|
||||
|
||||
/**
|
||||
* How much decoded text one session may carry, for the same reason.
|
||||
*
|
||||
* Not a truncation policy and not a second cap on tool rows -- the index writer
|
||||
* owns that, at 3 KB a row. This is the bound a non-streaming source needs and
|
||||
* a streaming one does not: a JSONL provider publishes each message as it reads
|
||||
* it, while this one holds the whole session before posting it. Measured on the
|
||||
* same database, the largest session's parts total 9.5 MB, so this is ~7x the
|
||||
* worst real one.
|
||||
*/
|
||||
const OPENCODE_CAPTURE_TEXT_LIMIT = 64 * 1024 * 1024
|
||||
|
||||
type CaptureRow = {
|
||||
messageId: string
|
||||
role: string | null
|
||||
partType: string
|
||||
partData: string
|
||||
messageTimeMs: number
|
||||
}
|
||||
|
||||
// A row this build cannot read is dropped rather than failing the session: the
|
||||
// schema probe only proves the columns exist, not what any one row holds.
|
||||
function toCaptureRow(value: unknown): CaptureRow | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const {
|
||||
message_id: messageId,
|
||||
role,
|
||||
part_type: partType,
|
||||
part_data: partData,
|
||||
message_time: messageTime
|
||||
} = record
|
||||
if (
|
||||
typeof messageId !== 'string' ||
|
||||
typeof partType !== 'string' ||
|
||||
typeof partData !== 'string' ||
|
||||
typeof messageTime !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
role: typeof role === 'string' ? role : null,
|
||||
partType,
|
||||
partData,
|
||||
messageTimeMs: messageTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call as the text a consumer sees: what was run, then what came back.
|
||||
*
|
||||
* Both halves live on one `part` here, where a file provider writes a
|
||||
* `tool_use` block and a matching `tool_result`, so this is one message where
|
||||
* those are two. The wording of each half is the shared one on purpose: a
|
||||
* search for a command should find it whichever agent ran it.
|
||||
*/
|
||||
function toolPartText(partData: string): string | null {
|
||||
const part = asRecord(parseJson(partData))
|
||||
if (!part) {
|
||||
return null
|
||||
}
|
||||
const state = asRecord(part.state)
|
||||
const lines = [toolCallText(part.tool, sharedToolInputSpelling(state?.input)), toolOutcome(state)]
|
||||
const text = lines.filter((line) => line !== null).join('\n')
|
||||
return text.trim() ? text : null
|
||||
}
|
||||
|
||||
// `state.error` is set on a failed or cancelled call and `state.output` on a
|
||||
// completed one; a call still running has neither, and its command line alone is
|
||||
// worth indexing. Preferring the error matches what the session actually shows.
|
||||
function toolOutcome(state: Record<string, unknown> | null): string | null {
|
||||
const error = state?.error
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return error
|
||||
}
|
||||
const output = state?.output
|
||||
return typeof output === 'string' && output.trim() ? output : null
|
||||
}
|
||||
|
||||
// OpenCode spells its file argument `filePath`; every other provider, and so the
|
||||
// shared key list, spells it `file_path`. Renaming the one key here keeps a
|
||||
// single list rather than teaching it one provider's casing.
|
||||
function sharedToolInputSpelling(input: unknown): unknown {
|
||||
const record = asRecord(input)
|
||||
if (!record || typeof record.filePath !== 'string' || typeof record.file_path === 'string') {
|
||||
return input
|
||||
}
|
||||
return { ...record, file_path: record.filePath }
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The session the panel renders, and every message the index folds. */
|
||||
export type OpenCodeSqliteCapture = {
|
||||
session: AiVaultSession | null
|
||||
messages: TranscriptMessage[]
|
||||
}
|
||||
|
||||
function captureRole(role: string | null): TranscriptMessageRole | null {
|
||||
return role === 'user' || role === 'assistant' ? role : null
|
||||
}
|
||||
|
||||
function buildCaptureQuery(): string {
|
||||
// Message order, then part order within a message: the same key the preview
|
||||
// read uses, run forwards and without the newest-N window.
|
||||
return `SELECT m.id AS message_id,
|
||||
json_extract(m.data, '$.role') AS role,
|
||||
json_extract(p.data, '$.type') AS part_type,
|
||||
p.data AS part_data,
|
||||
m.time_created AS message_time
|
||||
FROM message m
|
||||
JOIN part p ON p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES}
|
||||
ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one session's whole transcript.
|
||||
*
|
||||
* A turn's `text` and `reasoning` parts become one message, the way every other
|
||||
* provider hands a consumer one message per turn, so a phrase that runs across
|
||||
* two blocks of the same turn is still one indexable row. Reasoning folds into
|
||||
* that text because the shared block list already treats a thinking block as the
|
||||
* turn's own words. Each `tool` part is its own `tool` message, and they follow
|
||||
* the turn's words in transcript order -- the ordering a content-block decode
|
||||
* produces for every file provider.
|
||||
*/
|
||||
export function readOpenCodeSessionMessages(
|
||||
db: SyncDatabase,
|
||||
sessionId: string
|
||||
): TranscriptMessage[] {
|
||||
if (!canReadOpenCodeMessageParts(db)) {
|
||||
// Thrown for the same reason the part limit below throws: an empty capture
|
||||
// returned here is committed under a complete-read cursor, so the session
|
||||
// stays out of search with nothing on its row to say why and no retry.
|
||||
throw new Error(
|
||||
`OpenCode session ${sessionId} uses an unreadable message-part schema; its transcript was not read.`
|
||||
)
|
||||
}
|
||||
const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_PART_LIMIT + 1)
|
||||
if (rows.length > OPENCODE_CAPTURE_PART_LIMIT) {
|
||||
throw new Error(
|
||||
`OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_PART_LIMIT} text parts; its transcript was not read.`
|
||||
)
|
||||
}
|
||||
|
||||
const messages: TranscriptMessage[] = []
|
||||
let captured = 0
|
||||
let openMessageId: string | null = null
|
||||
let openWords: string[] = []
|
||||
let openTools: TranscriptMessage[] = []
|
||||
let openRole: TranscriptMessageRole | null = null
|
||||
let openTimestamp: string | null = null
|
||||
|
||||
const keep = (message: TranscriptMessage): void => {
|
||||
captured += message.text.length
|
||||
if (captured > OPENCODE_CAPTURE_TEXT_LIMIT) {
|
||||
throw new Error(
|
||||
`OpenCode session ${sessionId} decodes to more than ${OPENCODE_CAPTURE_TEXT_LIMIT} characters; its transcript was not read.`
|
||||
)
|
||||
}
|
||||
messages.push(message)
|
||||
}
|
||||
|
||||
// The turn's own words lead, its tool calls follow: the order
|
||||
// `transcriptMessagesFromContent` produces for a file provider's blocks.
|
||||
const flush = (): void => {
|
||||
const text = openRole && openWords.length > 0 ? boundedText(openWords.join('\n')) : null
|
||||
if (openRole && text) {
|
||||
keep({ role: openRole, text, timestamp: openTimestamp })
|
||||
}
|
||||
for (const tool of openTools) {
|
||||
keep(tool)
|
||||
}
|
||||
openWords = []
|
||||
openTools = []
|
||||
}
|
||||
|
||||
for (const value of rows) {
|
||||
const row = toCaptureRow(value)
|
||||
if (!row) {
|
||||
continue
|
||||
}
|
||||
if (row.messageId !== openMessageId) {
|
||||
flush()
|
||||
openMessageId = row.messageId
|
||||
openRole = captureRole(row.role)
|
||||
openTimestamp = timestampIso(row.messageTimeMs)
|
||||
}
|
||||
if (row.partType === 'tool') {
|
||||
const text = boundedText(toolPartText(row.partData) ?? '')
|
||||
if (text) {
|
||||
openTools.push({ role: 'tool', text, timestamp: openTimestamp })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const text = extractPartText(row.partData)
|
||||
if (text) {
|
||||
openWords.push(text)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one OpenCode session and its whole transcript from a single open of the
|
||||
* database, so the two can never describe different generations of the session.
|
||||
* @param args.dbPath - Absolute path to the opencode.db file.
|
||||
* @param args.sessionId - Primary key in the `session` table.
|
||||
* @param args.platform - Platform used for resume-command generation.
|
||||
* @returns The parsed session (null when it does not exist) and its messages.
|
||||
*/
|
||||
export async function captureOpenCodeSqliteSession(args: {
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): Promise<OpenCodeSqliteCapture> {
|
||||
return readOpenCodeDatabase({
|
||||
dbPath: args.dbPath,
|
||||
read: (db) => {
|
||||
const session = readOpenCodeSqliteSession({ db, ...args })
|
||||
// No session row is no transcript: the id names nothing in this database.
|
||||
return { session, messages: session ? readOpenCodeSessionMessages(db, args.sessionId) : [] }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
// The OpenCode 1.17.x schema, as the app itself creates it. Written out in full
|
||||
// rather than trimmed to the columns a reader names, because every read probes
|
||||
// for its columns and a trimmed fixture would pass a probe the real database
|
||||
// fails (or the reverse) without the test being able to tell.
|
||||
|
||||
const OPENCODE_SCHEMA = `
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT,
|
||||
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
|
||||
summary_diffs TEXT, revert TEXT, permission TEXT,
|
||||
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
|
||||
time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT,
|
||||
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL, data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE project (
|
||||
id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT,
|
||||
icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
|
||||
time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
export const OPENCODE_FIXTURE_EPOCH_MS = 1_740_000_000_000
|
||||
|
||||
/**
|
||||
* One `part` row. A bare string is a text part, which is what most turns are.
|
||||
*
|
||||
* The tool shape mirrors what OpenCode actually writes: the call's name and id
|
||||
* at the top level, and everything about the run nested under `state`.
|
||||
*/
|
||||
export type OpenCodeSqliteFixturePart =
|
||||
| string
|
||||
| { type: 'text' | 'reasoning'; text: string }
|
||||
| {
|
||||
type: 'tool'
|
||||
tool: string
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteFixtureTurn = {
|
||||
role: 'user' | 'assistant'
|
||||
/** One part row per entry, in the order the session recorded them. */
|
||||
parts: readonly OpenCodeSqliteFixturePart[]
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteFixtureSession = {
|
||||
id: string
|
||||
title?: string
|
||||
directory?: string
|
||||
turns: readonly OpenCodeSqliteFixtureTurn[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OpenCode SQLite database holding `sessions`.
|
||||
*
|
||||
* Each turn's parts are written as separate `part` rows, which is the shape a
|
||||
* reader has to reassemble; a fixture with one part per turn would never
|
||||
* exercise it. A session's `time_updated` is its last turn's timestamp, the
|
||||
* same stat the real database moves when a session gains a message.
|
||||
* @param dbPath - Where to create the database; parent directories are created.
|
||||
* @param sessions - The sessions to write, in the order they were created.
|
||||
*/
|
||||
export function writeOpenCodeSqliteDatabase(
|
||||
dbPath: string,
|
||||
sessions: readonly OpenCodeSqliteFixtureSession[]
|
||||
): void {
|
||||
mkdirSync(dirname(dbPath), { recursive: true })
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
if (!tableAlreadyThere(db)) {
|
||||
db.exec(OPENCODE_SCHEMA)
|
||||
db.prepare(
|
||||
`INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
|
||||
VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')`
|
||||
).run(OPENCODE_FIXTURE_EPOCH_MS, OPENCODE_FIXTURE_EPOCH_MS)
|
||||
}
|
||||
for (const session of sessions) {
|
||||
writeSession(db, session)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function tableAlreadyThere(db: SyncDatabase): boolean {
|
||||
return (
|
||||
db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='session'`).get() !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
function writeSession(db: SyncDatabase, session: OpenCodeSqliteFixtureSession): void {
|
||||
const created = OPENCODE_FIXTURE_EPOCH_MS
|
||||
const updated = created + Math.max(1, session.turns.length) * 60_000
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
|
||||
time_created, time_updated, agent, model, cost, tokens_input, tokens_output,
|
||||
tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES (?, 'proj-1', NULL, 'slug-1', ?, ?, '1.0.0', ?, ?, 'build', '{"id":"glm"}',
|
||||
0, 1, 1, 0, 0, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET time_updated = excluded.time_updated`
|
||||
).run(
|
||||
session.id,
|
||||
session.directory ?? '/tmp/opencode',
|
||||
session.title ?? 'OpenCode title',
|
||||
created,
|
||||
updated
|
||||
)
|
||||
appendTurns(db, session, created)
|
||||
}
|
||||
|
||||
/** Appends `turns` after whatever the session already holds. */
|
||||
export function appendTurns(
|
||||
db: SyncDatabase,
|
||||
session: OpenCodeSqliteFixtureSession,
|
||||
startMs: number
|
||||
): void {
|
||||
const insertMessage = db.prepare(
|
||||
`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
const insertPart = db.prepare(
|
||||
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
session.turns.forEach((turn, turnIndex) => {
|
||||
const at = startMs + (turnIndex + 1) * 60_000
|
||||
const messageId = `${session.id}-msg-${turnIndex}-${at}`
|
||||
insertMessage.run(
|
||||
messageId,
|
||||
session.id,
|
||||
at,
|
||||
at,
|
||||
JSON.stringify({ role: turn.role, time: { created: at } })
|
||||
)
|
||||
turn.parts.forEach((part, partIndex) => {
|
||||
insertPart.run(
|
||||
`${messageId}-part-${partIndex}`,
|
||||
messageId,
|
||||
session.id,
|
||||
at + partIndex,
|
||||
at + partIndex,
|
||||
JSON.stringify(partData(part, `${messageId}-call-${partIndex}`, at))
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function partData(
|
||||
part: OpenCodeSqliteFixturePart,
|
||||
callId: string,
|
||||
atMs: number
|
||||
): Record<string, unknown> {
|
||||
if (typeof part === 'string') {
|
||||
return { type: 'text', text: part }
|
||||
}
|
||||
if (part.type !== 'tool') {
|
||||
return { type: part.type, text: part.text }
|
||||
}
|
||||
const failed = typeof part.error === 'string'
|
||||
return {
|
||||
type: 'tool',
|
||||
tool: part.tool,
|
||||
callID: callId,
|
||||
state: {
|
||||
status: failed ? 'error' : 'completed',
|
||||
input: part.input ?? {},
|
||||
...(failed ? { error: part.error } : { output: part.output ?? '' }),
|
||||
title: part.tool,
|
||||
time: { start: atMs, end: atMs + 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one turn to an existing session and move its `time_updated`, the way
|
||||
* OpenCode does when a session continues.
|
||||
* @param dbPath - The fixture database to append to.
|
||||
* @param sessionId - The session to continue.
|
||||
* @param turn - The turn to append.
|
||||
*/
|
||||
export function appendOpenCodeSqliteTurn(
|
||||
dbPath: string,
|
||||
sessionId: string,
|
||||
turn: OpenCodeSqliteFixtureTurn
|
||||
): void {
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
const updated = currentUpdatedMs(db, sessionId)
|
||||
appendTurns(db, { id: sessionId, turns: [turn] }, updated)
|
||||
db.prepare('UPDATE session SET time_updated = ? WHERE id = ?').run(updated + 60_000, sessionId)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Throws rather than falling back to the epoch: a mistyped id would otherwise
|
||||
// append orphan rows and update nothing, leaving a test asserting over a
|
||||
// transcript that no session owns.
|
||||
function currentUpdatedMs(db: SyncDatabase, sessionId: string): number {
|
||||
const row = db.prepare('SELECT time_updated FROM session WHERE id = ?').get(sessionId)
|
||||
if (row === undefined) {
|
||||
throw new Error(`OpenCode fixture has no session ${sessionId} to append to`)
|
||||
}
|
||||
const updated = Object.values(row)[0]
|
||||
if (typeof updated !== 'number') {
|
||||
throw new Error(`OpenCode fixture session ${sessionId} has no numeric time_updated`)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@@ -250,14 +250,13 @@ describe('openCodeBusyTimeoutMs', () => {
|
||||
describe('openCodeDatabaseScanIssue', () => {
|
||||
const cantOpen = Object.assign(new Error('unable to open database file'), { errcode: 14 })
|
||||
|
||||
it('names the wal-index over a WSL share rather than repeating the driver string', () => {
|
||||
const issue = openCodeDatabaseScanIssue(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db',
|
||||
cantOpen
|
||||
)
|
||||
it('states the WSL share as a known limitation rather than an error to act on', () => {
|
||||
const dbPath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db'
|
||||
const issue = openCodeDatabaseScanIssue(dbPath, cantOpen)
|
||||
|
||||
expect(issue.kind).toBe('scope')
|
||||
expect(issue.message).toContain('\\\\wsl.localhost')
|
||||
expect(issue.path).toBe(dbPath)
|
||||
expect(issue.message).toBe("OpenCode sessions inside WSL can't be searched from Windows yet.")
|
||||
// Checkpointing cannot fix a share that refuses SQLite's locks, so the copy
|
||||
// must not send the user after the write-ahead log.
|
||||
expect(issue.message).not.toContain('write-ahead log')
|
||||
@@ -282,7 +281,7 @@ describe('openCodeDatabaseScanIssue', () => {
|
||||
)
|
||||
|
||||
expect(issue.message).not.toContain('is writing to')
|
||||
expect(issue.message).toContain('inside the distro')
|
||||
expect(issue.message).toBe("OpenCode sessions inside WSL can't be searched from Windows yet.")
|
||||
})
|
||||
|
||||
it('still blames a live writer for the same error on a local path', () => {
|
||||
|
||||
@@ -99,16 +99,15 @@ export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVau
|
||||
? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.`
|
||||
: kind === 'unreadable'
|
||||
? `OpenCode history in ${name} could not be read: ${errorMessage(error)}`
|
||||
: `OpenCode history in ${name} could not be read. ${unreadableShareAdvice(dbPath)}`
|
||||
: unreadableShareDetail(dbPath, name)
|
||||
return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail }
|
||||
}
|
||||
|
||||
function unreadableShareAdvice(dbPath: string): string {
|
||||
// Named only when the evidence supports it; a generic share gets generic copy.
|
||||
// Deliberately not "flush the write-ahead log": checkpointing changes nothing
|
||||
// here, and telling the user to try it would send them after a fix that cannot
|
||||
// work. The share itself is the blocker.
|
||||
function unreadableShareDetail(dbPath: string, name: string): string {
|
||||
// A known limitation, not a failure the user can act on: nothing they do on
|
||||
// the Windows side makes the share hand out SQLite's locks. Deliberately not
|
||||
// "flush the write-ahead log" either — checkpointing changes nothing here.
|
||||
return isWslUncPath(dbPath)
|
||||
? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.'
|
||||
: 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.'
|
||||
? "OpenCode sessions inside WSL can't be searched from Windows yet."
|
||||
: `OpenCode history in ${name} could not be read. Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.`
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { LazyWorkerThreadHost, type WorkerThreadFactory } from '../lazy-worker-thread-host'
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
OpenCodeSqliteCaptureRequest,
|
||||
OpenCodeSqliteCaptureValue,
|
||||
OpenCodeSqliteListRequest,
|
||||
OpenCodeSqliteListValue,
|
||||
OpenCodeSqliteParseRequest,
|
||||
OpenCodeSqliteWorkerRequest,
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
@@ -19,6 +22,9 @@ import { errorMessage } from './session-scanner-values'
|
||||
|
||||
export const LIST_TIMEOUT_MS = 30_000
|
||||
export const PARSE_TIMEOUT_MS = 15_000
|
||||
// Longer than a parse because it reads every part of the session rather than
|
||||
// the newest window, and shorter than nothing at all because the queue is FIFO.
|
||||
export const CAPTURE_TIMEOUT_MS = 30_000
|
||||
export const IDLE_TEARDOWN_MS = 30_000
|
||||
// After this many consecutive worker deaths, fail the remaining queued calls to
|
||||
// scan issues instead of respawning so a DB that reliably kills the worker can't
|
||||
@@ -31,6 +37,7 @@ export const MAX_CONSECUTIVE_DEATHS = 3
|
||||
type OpenCodeSqliteRequestBody =
|
||||
| Omit<OpenCodeSqliteListRequest, 'id'>
|
||||
| Omit<OpenCodeSqliteParseRequest, 'id'>
|
||||
| Omit<OpenCodeSqliteCaptureRequest, 'id'>
|
||||
|
||||
type PendingCall = {
|
||||
request: OpenCodeSqliteWorkerRequest
|
||||
@@ -44,6 +51,15 @@ type PendingCall = {
|
||||
// can surface a precise issue while keeping synchronous SQLite off the main thread.
|
||||
class OpenCodeSqliteWorkerUnavailableError extends Error {}
|
||||
|
||||
// One session failed, not the whole source: the scanner turns this throw into a
|
||||
// per-session scan issue and the search index records a failed read.
|
||||
function sessionReadFailure(err: unknown): Error {
|
||||
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
|
||||
return new Error('OpenCode SQLite background scanner could not start.')
|
||||
}
|
||||
return err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-thread bridge that runs OpenCode SQLite reads on a persistent worker
|
||||
* thread. Dispatches one request at a time (FIFO), times each request out from
|
||||
@@ -140,13 +156,43 @@ export class OpenCodeSqliteWorkerClient {
|
||||
{ kind: 'parse', dbPath: args.dbPath, sessionId: args.sessionId, platform: args.platform },
|
||||
PARSE_TIMEOUT_MS
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
|
||||
return value as AiVaultSession | null
|
||||
} catch (err) {
|
||||
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
|
||||
throw new Error('OpenCode SQLite background scanner could not start.')
|
||||
}
|
||||
// Reject only this session; the scanner turns the throw into a scan issue.
|
||||
throw err instanceof Error ? err : new Error(String(err))
|
||||
throw sessionReadFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one OpenCode session and its whole transcript on the worker.
|
||||
*
|
||||
* One request rather than a parse plus a second read: both halves then come
|
||||
* from a single open of the database, so the messages the index folds cannot
|
||||
* belong to a different generation of the session than the panel shows.
|
||||
* @param args.dbPath - Absolute path to the opencode.db file.
|
||||
* @param args.sessionId - Primary key in the `session` table.
|
||||
* @param args.platform - Platform used for resume-command generation.
|
||||
* @returns The session (null when it does not exist) and its messages;
|
||||
* rejects on worker timeout/crash so the read is recorded as failed.
|
||||
*/
|
||||
async capture(args: {
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): Promise<OpenCodeSqliteCaptureValue> {
|
||||
try {
|
||||
const value = await this.dispatch(
|
||||
{
|
||||
kind: 'capture',
|
||||
dbPath: args.dbPath,
|
||||
sessionId: args.sessionId,
|
||||
platform: args.platform
|
||||
},
|
||||
CAPTURE_TIMEOUT_MS
|
||||
)
|
||||
return parseOpenCodeSqliteCaptureValue(value)
|
||||
} catch (err) {
|
||||
throw sessionReadFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parentPort } from 'node:worker_threads'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
|
||||
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import type {
|
||||
@@ -30,6 +31,14 @@ async function handleRequest(
|
||||
})
|
||||
return { id: request.id, ok: true, value: { candidates, issues } }
|
||||
}
|
||||
if (request.kind === 'capture') {
|
||||
const capture = await captureOpenCodeSqliteSession({
|
||||
dbPath: request.dbPath,
|
||||
sessionId: request.sessionId,
|
||||
platform: request.platform
|
||||
})
|
||||
return { id: request.id, ok: true, value: capture }
|
||||
}
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: request.dbPath,
|
||||
sessionId: request.sessionId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import type { TranscriptMessage } from './session-transcript-consumers'
|
||||
|
||||
// Why: request/response shapes shared by the worker entry and the main-thread
|
||||
// client. Kept type-only (and electron-free) so importing it into the worker
|
||||
@@ -20,7 +21,21 @@ export type OpenCodeSqliteParseRequest = {
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteWorkerRequest = OpenCodeSqliteListRequest | OpenCodeSqliteParseRequest
|
||||
// Same arguments as `parse`, different answer: the session plus every message
|
||||
// the session holds. Its own kind rather than a flag on `parse` so the two
|
||||
// response shapes stay distinguishable at the type level on both sides.
|
||||
export type OpenCodeSqliteCaptureRequest = {
|
||||
id: number
|
||||
kind: 'capture'
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteWorkerRequest =
|
||||
| OpenCodeSqliteListRequest
|
||||
| OpenCodeSqliteParseRequest
|
||||
| OpenCodeSqliteCaptureRequest
|
||||
|
||||
// The list leg returns candidates plus the issues it accumulated; the worker
|
||||
// mutates a local array and hands it back so the caller can merge it into the
|
||||
@@ -30,6 +45,14 @@ export type OpenCodeSqliteListValue = {
|
||||
issues: AiVaultScanIssue[]
|
||||
}
|
||||
|
||||
// The session the panel shows, and the transcript the search index folds. Both
|
||||
// come from one open of the database, so the two can never disagree about which
|
||||
// generation of the session they describe.
|
||||
export type OpenCodeSqliteCaptureValue = {
|
||||
session: AiVaultSession | null
|
||||
messages: TranscriptMessage[]
|
||||
}
|
||||
|
||||
export type OpenCodeSqliteWorkerResponse =
|
||||
| { id: number; ok: true; value: unknown }
|
||||
| { id: number; ok: false; error: string }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { asRecord } from './session-scanner-record-value'
|
||||
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import type { TranscriptMessage } from './session-transcript-consumers'
|
||||
|
||||
// Why: a worker posts back a structured clone, which arrives as `unknown`. The
|
||||
// messages are checked one by one because they are written into the index as
|
||||
// rows keyed by role, and a value with no role at all would land under none.
|
||||
|
||||
function isTranscriptMessage(value: unknown): value is TranscriptMessage {
|
||||
const record = asRecord(value)
|
||||
return (
|
||||
record !== null &&
|
||||
(record.role === 'user' || record.role === 'assistant' || record.role === 'tool') &&
|
||||
typeof record.text === 'string' &&
|
||||
(record.timestamp === null || typeof record.timestamp === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `capture` response from the OpenCode SQLite worker.
|
||||
*
|
||||
* A message that is not one is dropped rather than failing the read: the rest
|
||||
* of the session is still worth indexing, and a row with a role the index has
|
||||
* no column for would be written under an empty one.
|
||||
* @param value - The worker's response value.
|
||||
* @returns The session and the messages the response carried.
|
||||
*/
|
||||
export function parseOpenCodeSqliteCaptureValue(value: unknown): OpenCodeSqliteCaptureValue {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return { session: null, messages: [] }
|
||||
}
|
||||
const messages = Array.isArray(record.messages) ? record.messages.filter(isTranscriptMessage) : []
|
||||
// Held to the same standard as the parse leg rather than validated harder: a
|
||||
// session this build dropped here but kept there would be in the panel and
|
||||
// absent from the index, which is worse than trusting our own worker.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker builds this with the repo's own reader; only the structured clone sits between.
|
||||
const session = (record.session ?? null) as AiVaultSession | null
|
||||
return { session, messages }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
|
||||
|
||||
// Why: resolve the built worker entry + own the process-wide shared client so
|
||||
@@ -70,3 +71,19 @@ export function parseOpenCodeSqliteSessionViaWorker(args: {
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return getSharedClient().parse(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one OpenCode SQLite session and its whole transcript through the shared
|
||||
* worker client.
|
||||
* @param args.dbPath - Absolute path to the opencode.db file.
|
||||
* @param args.sessionId - Primary key in the `session` table.
|
||||
* @param args.platform - Platform used for resume-command generation.
|
||||
* @returns The session and every message it holds.
|
||||
*/
|
||||
export function captureOpenCodeSqliteSessionViaWorker(args: {
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): Promise<OpenCodeSqliteCaptureValue> {
|
||||
return getSharedClient().capture(args)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
|
||||
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
||||
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
|
||||
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
@@ -479,6 +480,20 @@ describe('parseOpenCodeSqliteSession', () => {
|
||||
expect(session!.previewMessages).toEqual([])
|
||||
})
|
||||
|
||||
// The preview may degrade to nothing, but the search index may not: an empty
|
||||
// capture is committed under a complete-read cursor, so the session would stay
|
||||
// unsearchable with nothing on its row to say why and no retry.
|
||||
it('refuses to capture a transcript it cannot read the message parts of', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyMinimalOpenCodeSchema(db)
|
||||
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
||||
db.close()
|
||||
|
||||
await expect(
|
||||
captureOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_minimal', platform: 'darwin' })
|
||||
).rejects.toThrow(/unreadable message-part schema/)
|
||||
})
|
||||
|
||||
it('extracts model from older modelID schema', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
|
||||
@@ -130,7 +130,8 @@ function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function extractPartText(partData: string): string | null {
|
||||
/** The text a `type: 'text'` part carries; null for every other part shape. */
|
||||
export function extractPartText(partData: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(partData) as unknown
|
||||
const record =
|
||||
@@ -233,12 +234,13 @@ export async function parseOpenCodeSqliteSession(args: {
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return readOpenCodeDatabase({
|
||||
dbPath: args.dbPath,
|
||||
read: (db) => readSession({ db, ...args })
|
||||
read: (db) => readOpenCodeSqliteSession({ db, ...args })
|
||||
})
|
||||
}
|
||||
|
||||
// Extracted so the open wrapper owns the handle's lifetime.
|
||||
function readSession(args: {
|
||||
// Exported so a capture read can take the session and its whole transcript from
|
||||
// one open of the database rather than opening it twice.
|
||||
export function readOpenCodeSqliteSession(args: {
|
||||
db: SyncDatabase
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
|
||||
@@ -33,9 +33,12 @@ export function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
// Newline-terminated, the way an agent writes each record: a file whose last
|
||||
// line has no break is a transcript mid-write, and the reader deliberately
|
||||
// withholds that line from consumers until it is complete.
|
||||
export async function writeJsonlFile(filePath: string, records: unknown[]): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, jsonLines(records))
|
||||
await writeFile(filePath, `${jsonLines(records)}\n`)
|
||||
}
|
||||
|
||||
export async function writeAntigravityTranscript(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { jsonLines, type isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
|
||||
// Shared by the two halves of the every-agent vault, which are split only
|
||||
// because one file of every agent's layout is past the line ceiling.
|
||||
|
||||
export type AgentVaultRoots = ReturnType<typeof isolatedScanRoots>
|
||||
|
||||
/** Records as a file body: newline-terminated, the way an agent writes them. */
|
||||
export function jsonlBody(records: unknown[]): string {
|
||||
return `${jsonLines(records)}\n`
|
||||
}
|
||||
@@ -4,13 +4,8 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import {
|
||||
isolatedScanRoots,
|
||||
jsonLines,
|
||||
writeAntigravityScannerFixture,
|
||||
writeOmpScannerFixture,
|
||||
writePrimeAgentScannerFixture
|
||||
} from './session-scanner-test-fixtures'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
import { writeEveryAgentVault } from './session-scanner-every-agent-fixture'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
@@ -373,348 +368,8 @@ describe('scanAiVaultSessions', () => {
|
||||
it('indexes every supported agent transcript format with native resume commands', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-all-agents-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
|
||||
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Claude title' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-05-01T10:01:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'codex-session', cwd: '/tmp/codex' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-01T10:01:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Codex title' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.geminiSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.geminiSessionsDir, 'gemini-session.json'),
|
||||
JSON.stringify({
|
||||
sessionId: 'gemini-session',
|
||||
startTime: '2026-05-01T10:02:00.000Z',
|
||||
lastUpdated: '2026-05-01T10:02:01.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-05-01T10:02:00.000Z',
|
||||
content: [{ text: 'Gemini title' }]
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
timestamp: '2026-05-01T10:02:01.000Z',
|
||||
model: 'gemini-2.5-pro',
|
||||
tokens: { input: 10, output: 5 }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId)
|
||||
|
||||
await mkdir(roots.copilotSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.copilotSessionsDir, 'copilot-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session.start',
|
||||
data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' },
|
||||
timestamp: '2026-05-01T10:03:00.000Z'
|
||||
},
|
||||
{
|
||||
type: 'session.info',
|
||||
data: {
|
||||
infoType: 'folder_trust',
|
||||
message: 'Folder /tmp/copilot has been added to trusted folders.'
|
||||
},
|
||||
timestamp: '2026-05-01T10:03:01.000Z'
|
||||
},
|
||||
{
|
||||
type: 'user.message',
|
||||
data: { transformedContent: 'Copilot title' },
|
||||
timestamp: '2026-05-01T10:03:02.000Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
role: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Cursor title' }] }
|
||||
},
|
||||
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
|
||||
await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'),
|
||||
JSON.stringify({
|
||||
id: 'opencode-session',
|
||||
directory: '/tmp/opencode',
|
||||
title: 'OpenCode title',
|
||||
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
summary: { title: 'OpenCode title' },
|
||||
time: { created: 1_777_634_000_000 },
|
||||
tokens: { input: 7, output: 3 }
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
|
||||
recursive: true
|
||||
})
|
||||
await writeFile(
|
||||
join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
|
||||
JSON.stringify({
|
||||
info: { id: 'grok-session', cwd: '/tmp/grok' },
|
||||
session_summary: '',
|
||||
created_at: '2026-05-01T10:04:00.000Z',
|
||||
updated_at: '2026-05-01T10:04:01.000Z',
|
||||
num_chat_messages: 2,
|
||||
current_model_id: 'grok-build',
|
||||
head_branch: 'feature/grok-vault'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(
|
||||
roots.grokSessionsDir,
|
||||
encodeURIComponent('/tmp/grok'),
|
||||
'grok-session',
|
||||
'chat_history.jsonl'
|
||||
),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<user_info>context</user_info><user_query>Grok title</user_query>'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 'assistant', content: 'Done' }
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.hermesSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
|
||||
JSON.stringify({
|
||||
session_id: 'hermes-session',
|
||||
model: 'hermes-1',
|
||||
cwd: '/tmp/hermes',
|
||||
session_start: '2026-05-01T10:05:00.000Z',
|
||||
last_updated: '2026-05-01T10:05:01.000Z',
|
||||
messages: [{ role: 'user', content: 'Hermes title' }]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'),
|
||||
JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' })
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'),
|
||||
JSON.stringify({
|
||||
message_history: [
|
||||
{
|
||||
kind: 'request',
|
||||
timestamp: '2026-05-01T10:06:00.000Z',
|
||||
parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'openclaw-session',
|
||||
timestamp: '2026-05-01T10:07:00.000Z',
|
||||
cwd: '/tmp/openclaw'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:07:01.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.piSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.piSessionsDir, 'pi-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'pi-session',
|
||||
timestamp: '2026-05-01T10:08:00.000Z',
|
||||
cwd: '/tmp/pi'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:08:01.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const ompSessionFile = await writeOmpScannerFixture(roots.ompSessionsDir)
|
||||
const primeAgentSessionFile = await writePrimeAgentScannerFixture(roots.primeAgentSessionsDir)
|
||||
|
||||
await mkdir(roots.devinTranscriptsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.devinTranscriptsDir, 'devin-session.json'),
|
||||
JSON.stringify({
|
||||
session_id: 'devin-session',
|
||||
working_directory: '/tmp/devin',
|
||||
agent: { model_name: 'swe-1-6-fast' },
|
||||
steps: [
|
||||
{
|
||||
metadata: {
|
||||
created_at: '2026-05-01T10:10:00.000Z',
|
||||
is_user_input: true,
|
||||
metrics: { input_tokens: 1, output_tokens: 2 }
|
||||
},
|
||||
text: 'Devin vault title'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(roots.droidSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.droidSessionsDir, 'droid-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'system',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:00.000Z',
|
||||
model: 'droid-model',
|
||||
cwd: '/tmp/droid'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:01.000Z',
|
||||
role: 'user',
|
||||
text: 'Droid title'
|
||||
},
|
||||
{
|
||||
type: 'completion',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:02.000Z',
|
||||
usage: { input_tokens: 2, output_tokens: 3 }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const clineSessionId = 'cline-session'
|
||||
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
|
||||
await mkdir(clineSessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.json`),
|
||||
JSON.stringify({
|
||||
session_id: clineSessionId,
|
||||
started_at: '2026-05-01T10:10:30.000Z',
|
||||
model: 'cline-model',
|
||||
cwd: '/tmp/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
updated_at: '2026-05-01T10:10:31.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
|
||||
// with the work dir resolved from the top-level session_index.jsonl.
|
||||
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
|
||||
await mkdir(join(kimiSessionDir, 'agents', 'main'), { recursive: true })
|
||||
await writeFile(
|
||||
join(kimiSessionDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
createdAt: '2026-05-01T10:11:00.000Z',
|
||||
updatedAt: '2026-05-01T10:11:05.000Z',
|
||||
title: 'Kimi vault title',
|
||||
lastPrompt: 'Kimi vault title',
|
||||
agents: { main: { type: 'main', parentAgentId: null } }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(root, 'session_index.jsonl'),
|
||||
jsonLines([
|
||||
{ sessionId: 'session_kimi-session', sessionDir: kimiSessionDir, workDir: '/tmp/kimi' }
|
||||
])
|
||||
)
|
||||
await writeFile(
|
||||
join(kimiSessionDir, 'agents', 'main', 'wire.jsonl'),
|
||||
jsonLines([
|
||||
{ type: 'config.update', modelAlias: 'kimi-k2.6', time: 1781853559132 },
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Kimi vault title' }],
|
||||
origin: { kind: 'user' }
|
||||
},
|
||||
time: 1781853559164
|
||||
},
|
||||
{
|
||||
type: 'context.append_loop_event',
|
||||
event: { type: 'content.part', part: { type: 'text', text: 'Kimi reply' } },
|
||||
time: 1781853559177
|
||||
},
|
||||
{ type: 'context.append_loop_event', event: { type: 'step.end' }, time: 1781853559178 },
|
||||
{
|
||||
type: 'usage.record',
|
||||
model: 'kimi-k2.6',
|
||||
usage: { inputOther: 4, output: 6, inputCacheRead: 0, inputCacheCreation: 0 },
|
||||
usageScope: 'turn',
|
||||
time: 1781853559178
|
||||
}
|
||||
])
|
||||
)
|
||||
const { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile } =
|
||||
await writeEveryAgentVault(root)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
|
||||
|
||||
|
||||
@@ -16,12 +16,18 @@ const OPENCODE_SQLITE_SESSION = {
|
||||
agent: 'opencode' as const,
|
||||
sessionId: 'sqlite-session'
|
||||
}
|
||||
const OPENCODE_SQLITE_MESSAGES = [
|
||||
{ role: 'user' as const, text: 'ask sqlite', timestamp: null },
|
||||
{ role: 'assistant' as const, text: 'reply sqlite', timestamp: null }
|
||||
]
|
||||
|
||||
// Stands in for the worker thread: the point is that its messages never come
|
||||
// back over the channel, not what the SQLite read returns.
|
||||
// Stands in for the worker thread: the point is which leg the reader asks for
|
||||
// and that what comes back reaches the channel, not what the SQLite read returns.
|
||||
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof OpenCodeSqliteWorkerSpawn>()),
|
||||
parseOpenCodeSqliteSessionViaWorker: () => Promise.resolve(OPENCODE_SQLITE_SESSION)
|
||||
parseOpenCodeSqliteSessionViaWorker: () => Promise.resolve(OPENCODE_SQLITE_SESSION),
|
||||
captureOpenCodeSqliteSessionViaWorker: () =>
|
||||
Promise.resolve({ session: OPENCODE_SQLITE_SESSION, messages: OPENCODE_SQLITE_MESSAGES })
|
||||
}))
|
||||
import type * as OpenCodeSqliteWorkerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
|
||||
import {
|
||||
@@ -304,7 +310,7 @@ it('serializes overlapping parses of one path so no consumer read is orphaned',
|
||||
expect(second?.messageCount).toBe(10)
|
||||
})
|
||||
|
||||
it('reports a read whose parser cannot publish its messages as not complete', async () => {
|
||||
it('publishes an OpenCode SQLite session over the channel and reports it complete', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-opencode-'))
|
||||
tempRoots.push(root)
|
||||
const dbPath = join(root, 'opencode.db')
|
||||
@@ -327,8 +333,9 @@ it('reports a read whose parser cannot publish its messages as not complete', as
|
||||
|
||||
expect(session).toEqual(OPENCODE_SQLITE_SESSION)
|
||||
expect(consumer.reads).toHaveLength(1)
|
||||
expect(consumer.reads[0].messages).toEqual([])
|
||||
expect(consumer.reads[0].outcome?.incomplete).toBe(true)
|
||||
expect(consumer.reads[0].messages).toEqual(OPENCODE_SQLITE_MESSAGES)
|
||||
expect(consumer.reads[0].outcome?.incomplete).toBe(false)
|
||||
consumer.unregister()
|
||||
})
|
||||
|
||||
it('reports the transcript size, not the cache key, as a whole-file read offset', async () => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
// Only the thread hop is replaced: the implementations below are the repo's own
|
||||
// in-process readers, which the worker entry calls on the other side.
|
||||
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => {
|
||||
const list = await import('./session-scanner-opencode-sqlite-list')
|
||||
const parse = await import('./session-scanner-opencode-sqlite')
|
||||
const capture = await import('./session-scanner-opencode-sqlite-capture')
|
||||
return {
|
||||
resolveOpenCodeSqliteWorkerEntryPath: () => null,
|
||||
listOpenCodeSqliteSessionsViaWorker: (
|
||||
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
|
||||
) => list.listOpenCodeSqliteSessions(args),
|
||||
parseOpenCodeSqliteSessionViaWorker: (
|
||||
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
|
||||
) => parse.parseOpenCodeSqliteSession(args),
|
||||
captureOpenCodeSqliteSessionViaWorker: (
|
||||
args: Parameters<typeof capture.captureOpenCodeSqliteSession>[0]
|
||||
) => capture.captureOpenCodeSqliteSession(args)
|
||||
}
|
||||
})
|
||||
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { writeEveryAgentVault } from './session-scanner-every-agent-fixture'
|
||||
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
|
||||
import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import {
|
||||
registerTranscriptConsumer,
|
||||
resetTranscriptConsumersForTests,
|
||||
type TranscriptMessage
|
||||
} from './session-transcript-consumers'
|
||||
|
||||
/*
|
||||
* The guard the OpenCode capture gap needed.
|
||||
*
|
||||
* Every consumer of the transcript reader -- the search index today, a digest
|
||||
* tomorrow -- sees an agent only through the messages its parser publishes. A
|
||||
* parser can list a session, show a preview and resume it correctly while
|
||||
* publishing nothing at all, which is exactly how 606 OpenCode sessions came to
|
||||
* hold zero indexed messages. Nothing above this layer can tell the difference,
|
||||
* so the assertion has to live here: one fixture per supported agent, read the
|
||||
* way the app reads it, and every agent has to say something.
|
||||
*/
|
||||
|
||||
const OPENCODE_SQLITE_SESSION = 'ses_capture_guard'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
type CapturedRead = { agent: AiVaultAgent; path: string; messages: TranscriptMessage[] }
|
||||
|
||||
async function readEveryAgentVault(): Promise<CapturedRead[]> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-every-agent-'))
|
||||
tempRoots.push(root)
|
||||
const { roots } = await writeEveryAgentVault(root)
|
||||
const dbPath = join(root, 'opencode-db', 'opencode.db')
|
||||
writeOpenCodeSqliteDatabase(dbPath, [
|
||||
{
|
||||
id: OPENCODE_SQLITE_SESSION,
|
||||
turns: [
|
||||
{ role: 'user', parts: ['what does the sqlite reader publish'] },
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'Weighing which parts carry words.' },
|
||||
'Every part of every turn.',
|
||||
{
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
input: { command: 'rg --count quokka' },
|
||||
output: 'src/main/ai-vault: 3'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const reads: CapturedRead[] = []
|
||||
registerTranscriptConsumer({
|
||||
beginRead: (start) => {
|
||||
const read: CapturedRead = {
|
||||
agent: start.candidate.agent,
|
||||
path: start.candidate.file.path,
|
||||
messages: []
|
||||
}
|
||||
reads.push(read)
|
||||
return { message: (message) => read.messages.push(message), finish: () => undefined }
|
||||
}
|
||||
})
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
opencodeDbPaths: [dbPath],
|
||||
platform: 'darwin',
|
||||
limit: 40
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
return reads
|
||||
}
|
||||
|
||||
function spokeIn(read: CapturedRead): boolean {
|
||||
return read.messages.some((message) => message.role === 'user' || message.role === 'assistant')
|
||||
}
|
||||
|
||||
it('publishes at least one user or assistant message for every source it reads', async () => {
|
||||
const reads = await readEveryAgentVault()
|
||||
|
||||
// Per source, not per agent: OpenCode has two storage shapes, and asking only
|
||||
// that *some* OpenCode session spoke is exactly the question that read as
|
||||
// healthy while every SQLite session in the vault was silent.
|
||||
expect(reads.filter((read) => !spokeIn(read)).map((read) => read.path)).toEqual([])
|
||||
// And the vault really does cover every agent, so a new one cannot be added
|
||||
// without a fixture that proves it publishes.
|
||||
expect(new Set(reads.map((read) => read.agent))).toEqual(new Set(AI_VAULT_AGENTS))
|
||||
})
|
||||
|
||||
it('publishes an OpenCode SQLite session through the same channel as every file source', async () => {
|
||||
const reads = await readEveryAgentVault()
|
||||
|
||||
const sqliteRead = reads.find(
|
||||
(read) => splitOpenCodeSqliteCandidate(read.path)?.sessionId === OPENCODE_SQLITE_SESSION
|
||||
)
|
||||
expect(sqliteRead?.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
text: 'what does the sqlite reader publish',
|
||||
timestamp: expect.any(String)
|
||||
},
|
||||
{
|
||||
// Reasoning folds into the turn's own words, ahead of the text part it
|
||||
// preceded, exactly as a thinking block does for a file provider.
|
||||
role: 'assistant',
|
||||
text: 'Weighing which parts carry words.\nEvery part of every turn.',
|
||||
timestamp: expect.any(String)
|
||||
},
|
||||
{
|
||||
// The call line and what came back, in one message: OpenCode writes both
|
||||
// on one part where a file provider writes a call block and a result.
|
||||
role: 'tool',
|
||||
text: 'bash: rg --count quokka\nsrc/main/ai-vault: 3',
|
||||
timestamp: expect.any(String)
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('gives an OpenCode session the same three roles a file provider publishes', async () => {
|
||||
const reads = await readEveryAgentVault()
|
||||
|
||||
const sqliteRead = reads.find(
|
||||
(read) => splitOpenCodeSqliteCandidate(read.path)?.sessionId === OPENCODE_SQLITE_SESSION
|
||||
)
|
||||
expect(new Set(sqliteRead?.messages.map((message) => message.role))).toEqual(
|
||||
new Set(['user', 'assistant', 'tool'])
|
||||
)
|
||||
})
|
||||
@@ -32,6 +32,12 @@ it('joins text blocks and appends tool blocks as their own messages', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts the capitalised Text block Codex writes for a completed agent message', () => {
|
||||
expect(
|
||||
transcriptMessagesFromContent('assistant', [{ type: 'Text', text: 'the reply' }], AT)
|
||||
).toEqual([{ role: 'assistant', text: 'the reply', timestamp: AT }])
|
||||
})
|
||||
|
||||
it('reads a tool result carried on a user record as a tool message', () => {
|
||||
expect(
|
||||
transcriptMessagesFromContent(
|
||||
|
||||
@@ -93,7 +93,8 @@ export function transcriptMessagesFromContent(
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
const type = typeof item.type === 'string' ? item.type : null
|
||||
// Codex 0.153+ item_completed blocks are typed `Text`; the set is lowercase.
|
||||
const type = typeof item.type === 'string' ? item.type.toLowerCase() : null
|
||||
if (type === 'tool_use') {
|
||||
pushMessage(messages, 'tool', toolCallText(item.name, item.input), timestamp)
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
|
||||
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
|
||||
import {
|
||||
@@ -159,12 +159,11 @@ export async function readWholeTranscript(args: {
|
||||
args.stats.fullParses++
|
||||
args.stats.bytesRead += file.sizeBytes ?? 0
|
||||
}
|
||||
const publishes = parserPublishesMessages(args.candidate)
|
||||
const channel = new TranscriptMessageChannel()
|
||||
channel.beginRead({ candidate: args.candidate, mode: 'replace', previousByteOffset: 0 })
|
||||
try {
|
||||
const session = await parseAgentSessionFile(args.candidate, args.platform, channel)
|
||||
channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: !publishes })
|
||||
channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: false })
|
||||
return session
|
||||
} catch (error) {
|
||||
channel.finishRead({ session: null, byteOffset: 0, incomplete: true })
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
query,
|
||||
type CanUseTool,
|
||||
type Options,
|
||||
type PermissionMode,
|
||||
type SDKUserMessage,
|
||||
type SpawnedProcess as SdkSpawnedProcess,
|
||||
type SpawnOptions as SdkSpawnOptions
|
||||
@@ -143,7 +144,7 @@ function recordingSpawner(spawns: SpawnSeen[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedLaunch(launchArgs: string[]) {
|
||||
function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = []) {
|
||||
const record = {
|
||||
sessionId: 'contract-pin-session',
|
||||
provider: 'claude',
|
||||
@@ -161,7 +162,8 @@ function resolvedLaunch(launchArgs: string[]) {
|
||||
store: { getRecord: () => record } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async () => '/repos/workspace-1',
|
||||
resolveCommand: () => FAKE_CLI,
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true })
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
resolvePermissionMode: () => permissionMode
|
||||
})({ identity: { sessionId: record.sessionId } as never })
|
||||
}
|
||||
|
||||
@@ -338,9 +340,9 @@ describe('Claude Agent SDK contract pins', () => {
|
||||
it('produces a matching CLI flag for every pre-SDK argv entry', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const spawns: SpawnSeen[] = []
|
||||
// Driven by the real resolver, so the argv walk covers the durable-launchArgs
|
||||
// translation and its merge order, not a hand-written options literal.
|
||||
const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high'])
|
||||
// Driven by the real resolver, so the argv walk covers its option set and merge order,
|
||||
// not a hand-written options literal.
|
||||
const launch = await resolvedLaunch('bypassPermissions', ['--model', 'claude-sonnet-4-5'])
|
||||
await drainQuery({
|
||||
...launch.options,
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
@@ -352,15 +354,20 @@ describe('Claude Agent SDK contract pins', () => {
|
||||
|
||||
expect(spawns).toHaveLength(1)
|
||||
const argv = normalizeArgv(spawns[0]!.args)
|
||||
// Typed-first translation must not also spell the flag through extraArgs.
|
||||
for (const flag of ['--model', '--effort']) {
|
||||
// Agent Permissions reaches the child as the SDK's own typed pair, spelled exactly once each.
|
||||
// `--allow-dangerously-skip-permissions` is what the SDK emits for the allow flag; the CLI
|
||||
// refuses `bypassPermissions` without it, so a rename upstream must fail here rather than
|
||||
// silently return a Yolo user to permission prompts.
|
||||
for (const flag of ['--permission-mode', '--allow-dangerously-skip-permissions']) {
|
||||
expect(
|
||||
argv.filter((arg) => arg === flag),
|
||||
`${flag} occurrences`
|
||||
).toHaveLength(1)
|
||||
}
|
||||
expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5')
|
||||
expect(argv[argv.indexOf('--effort') + 1]).toBe('high')
|
||||
expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('bypassPermissions')
|
||||
// Configured CLI arguments are a terminal concern; a record written before they stopped
|
||||
// being read must not smuggle one back into the child's argv.
|
||||
expect(argv).not.toContain('--model')
|
||||
// Headless print mode is the SDK's only mode; `query()` never passes `-p`,
|
||||
// and if the SDK ever started passing it this pin would notice.
|
||||
const impliedByHeadlessQuery = new Set(['-p'])
|
||||
|
||||
@@ -11,10 +11,10 @@ import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-str
|
||||
import {
|
||||
CLAUDE_DEFAULT_SETTING_SOURCES,
|
||||
CLAUDE_STRUCTURED_BASE_OPTIONS,
|
||||
claudeSdkOptionsForLaunchArgs,
|
||||
claudeSessionIdForOrcaSession,
|
||||
createClaudeStructuredLaunchResolver
|
||||
} from './claude-structured-launch-resolution'
|
||||
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
|
||||
|
||||
const SESSION_ID = 'orca-session-1'
|
||||
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
||||
@@ -55,13 +55,16 @@ function makeExecutable(path: string): void {
|
||||
function resolverFor(
|
||||
value: AgentSessionRecord | null,
|
||||
resolveEnv?: () => Record<string, string>,
|
||||
stripAuthEnv = false
|
||||
stripAuthEnv = false,
|
||||
// Manual by default so a test that is not about permissions is not silently about them.
|
||||
agentDefaultArgs: Record<string, string> = { claude: '' }
|
||||
) {
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv }),
|
||||
resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }),
|
||||
...(resolveEnv ? { resolveEnv } : {})
|
||||
})
|
||||
}
|
||||
@@ -119,6 +122,7 @@ describe('claude structured launch resolution', () => {
|
||||
supportedDialogKinds: [],
|
||||
extraArgs: { 'replay-user-messages': null },
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
permissionMode: 'default',
|
||||
sessionId: first.providerSessionId
|
||||
})
|
||||
expect(first.options.resume).toBeUndefined()
|
||||
@@ -190,42 +194,55 @@ describe('claude structured launch resolution', () => {
|
||||
expect(launch.options.resumeSessionAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves durable Claude launch arguments as typed options and extraArgs', async () => {
|
||||
// Agent Permissions is stored as the bypass flag inside the launch arguments, so presence of
|
||||
// that flag — not the whole string — is what Yolo means, exactly as a terminal launch reads it.
|
||||
it.each([
|
||||
['--dangerously-skip-permissions'],
|
||||
['--dangerously-skip-permissions --model Opus'],
|
||||
['--model Opus --dangerously-skip-permissions']
|
||||
])('starts a Yolo session in bypassPermissions for args %s', async (claude) => {
|
||||
const launch = await resolverFor(record(), undefined, false, { claude })({ identity: IDENTITY })
|
||||
|
||||
expect(launch.options.permissionMode).toBe('bypassPermissions')
|
||||
// The SDK refuses bypassPermissions unless the allow flag rides with it.
|
||||
expect(launch.options.allowDangerouslySkipPermissions).toBe(true)
|
||||
})
|
||||
|
||||
// The common profile: the toggle has never been used, so it has written nothing, and the
|
||||
// default for the key it did not write is the bypass flag — the posture the terminal has
|
||||
// always given these users.
|
||||
it('starts a session that never opened Agent settings in bypassPermissions', async () => {
|
||||
const launch = await resolverFor(record(), undefined, false, {})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.options.permissionMode).toBe('bypassPermissions')
|
||||
expect(launch.options.allowDangerouslySkipPermissions).toBe(true)
|
||||
})
|
||||
|
||||
// Manual is stored as an empty string, which owns the key and so beats the shipped default.
|
||||
it.each([[''], ['--model Opus']])(
|
||||
'leaves a Manual session prompting for args %s',
|
||||
async (claude) => {
|
||||
const launch = await resolverFor(record(), undefined, false, { claude })({
|
||||
identity: IDENTITY
|
||||
})
|
||||
|
||||
expect(launch.options.permissionMode).toBe('default')
|
||||
expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
// The configured CLI arguments are a terminal concern: a durable record written before they
|
||||
// stopped being read must not smuggle one back into the child.
|
||||
it("ignores the record's durable launch arguments", async () => {
|
||||
const launch = await resolverFor(
|
||||
record({
|
||||
launchArgs: [
|
||||
'--model',
|
||||
'claude-sonnet-4-5',
|
||||
'--effort',
|
||||
'high',
|
||||
'--dangerously-skip-permissions'
|
||||
]
|
||||
launchArgs: ['--model', 'claude-sonnet-4-5', '--dangerously-skip-permissions']
|
||||
})
|
||||
)({ identity: IDENTITY })
|
||||
|
||||
expect(launch.options.model).toBe('claude-sonnet-4-5')
|
||||
expect(launch.options.effort).toBe('high')
|
||||
expect(launch.options.extraArgs).toEqual({
|
||||
'dangerously-skip-permissions': null,
|
||||
'replay-user-messages': null
|
||||
})
|
||||
})
|
||||
|
||||
it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => {
|
||||
// The catalog's own output: each flag lands in exactly one place, so the SDK
|
||||
// cannot emit it twice with two different values.
|
||||
expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
// An effort the SDK's union does not name still reaches the CLI, unchanged.
|
||||
expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({
|
||||
extraArgs: { effort: 'ultra' }
|
||||
})
|
||||
expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({
|
||||
extraArgs: { settings: '/tmp/s.json' }
|
||||
})
|
||||
expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/)
|
||||
expect(launch.options.model).toBeUndefined()
|
||||
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
|
||||
expect(launch.options.permissionMode).toBe('default')
|
||||
})
|
||||
|
||||
it('keeps the session launch environment pinned after account settings change', async () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type {
|
||||
Options as ClaudeAgentSdkOptions,
|
||||
PermissionMode
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -35,6 +38,8 @@ export type ClaudeStructuredSdkOptions = Pick<
|
||||
| 'extraArgs'
|
||||
| 'model'
|
||||
| 'effort'
|
||||
| 'permissionMode'
|
||||
| 'allowDangerouslySkipPermissions'
|
||||
| 'sessionId'
|
||||
| 'resume'
|
||||
| 'resumeSessionAt'
|
||||
@@ -58,8 +63,6 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = {
|
||||
extraArgs: { 'replay-user-messages': null }
|
||||
}
|
||||
|
||||
const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
|
||||
function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
@@ -71,47 +74,18 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Recor
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the record's durable launch arguments into SDK options.
|
||||
* Agent Permissions as query-start options.
|
||||
*
|
||||
* Typed option first so a flag is never emitted twice; `extraArgs` carries
|
||||
* anything without one. A token expressible neither way is refused rather than
|
||||
* dropped — a silent drop is how this lane loses launch flags.
|
||||
* The SDK refuses `bypassPermissions` unless the allow flag rides with it, so the two are built
|
||||
* here together and never emitted apart. The prompting mode is stated rather than left out: the
|
||||
* SDK fills an absent mode with `default` anyway, and saying so keeps the launch readable.
|
||||
*/
|
||||
export function claudeSdkOptionsForLaunchArgs(
|
||||
args: readonly string[]
|
||||
): Pick<ClaudeStructuredSdkOptions, 'model' | 'effort' | 'extraArgs'> {
|
||||
let model: string | undefined
|
||||
let effort: EffortLevel | undefined
|
||||
const extraArgs: Record<string, string | null> = {}
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const token = args[index] ?? ''
|
||||
if (!token.startsWith('--') || token.length <= 2) {
|
||||
throw new Error(
|
||||
`claude launch argument ${token} has no SDK option; refusing rather than dropping it`
|
||||
)
|
||||
}
|
||||
const equals = token.indexOf('=')
|
||||
const flag = equals === -1 ? token : token.slice(0, equals)
|
||||
let value = equals === -1 ? null : token.slice(equals + 1)
|
||||
if (value === null) {
|
||||
const next = args[index + 1]
|
||||
if (next !== undefined && !next.startsWith('-')) {
|
||||
value = next
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if (flag === '--model' && value !== null) {
|
||||
model = value
|
||||
} else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) {
|
||||
effort = value as EffortLevel
|
||||
} else {
|
||||
extraArgs[flag.slice(2)] = value
|
||||
}
|
||||
}
|
||||
export function claudeStructuredPermissionOptions(
|
||||
mode: PermissionMode
|
||||
): Pick<ClaudeStructuredSdkOptions, 'permissionMode' | 'allowDangerouslySkipPermissions'> {
|
||||
return {
|
||||
...(model === undefined ? {} : { model }),
|
||||
...(effort === undefined ? {} : { effort }),
|
||||
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {})
|
||||
permissionMode: mode,
|
||||
...(mode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +116,8 @@ export type ClaudeStructuredLaunchResolverDeps = {
|
||||
* inherit a guess. Build it with claudeStructuredAuthPolicyForSettings.
|
||||
*/
|
||||
resolveAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
/** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */
|
||||
resolvePermissionMode?: () => Promise<PermissionMode> | PermissionMode
|
||||
/** How long an in-flight account switch may hold a launch before it is refused. */
|
||||
authSwitchSettleTimeoutMs?: number
|
||||
/** Account state for the managed-account gate; null when it cannot be read, which refuses. */
|
||||
@@ -219,7 +195,11 @@ export function createClaudeStructuredLaunchResolver(
|
||||
head?.handle.provider === 'claude'
|
||||
? head.handle.sessionId
|
||||
: claudeSessionIdForOrcaSession(identity.sessionId)
|
||||
const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? [])
|
||||
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
|
||||
// concern, and the permission mode they used to smuggle in is a typed option now.
|
||||
const permission = claudeStructuredPermissionOptions(
|
||||
(await deps.resolvePermissionMode?.()) ?? 'default'
|
||||
)
|
||||
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
|
||||
const auth = await deps.resolveAuthPolicy()
|
||||
const overlay = await deps.resolveEnv?.()
|
||||
@@ -256,9 +236,8 @@ export function createClaudeStructuredLaunchResolver(
|
||||
return {
|
||||
pathToClaudeCodeExecutable: command,
|
||||
options: {
|
||||
...durable,
|
||||
...CLAUDE_STRUCTURED_BASE_OPTIONS,
|
||||
extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs },
|
||||
...permission,
|
||||
...(head?.handle.provider === 'claude'
|
||||
? {
|
||||
resume: providerSessionId,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
|
||||
|
||||
describe('claudeStructuredPermissionModeForSettings', () => {
|
||||
// The three states the Agent Permissions toggle can leave behind. The untouched case is the
|
||||
// common one and the easiest to get wrong: the toggle writes nothing until it is used, and the
|
||||
// default Orca ships for the key it did not write is the bypass flag — which is what a terminal
|
||||
// launch has always applied to an untouched profile.
|
||||
it('bypasses when the user has never opened Agent settings', () => {
|
||||
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: {} })).toBe(
|
||||
'bypassPermissions'
|
||||
)
|
||||
expect(claudeStructuredPermissionModeForSettings({})).toBe('bypassPermissions')
|
||||
expect(claudeStructuredPermissionModeForSettings(null)).toBe('bypassPermissions')
|
||||
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { codex: '' } })).toBe(
|
||||
'bypassPermissions'
|
||||
)
|
||||
})
|
||||
|
||||
it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => {
|
||||
for (const claude of [
|
||||
'--dangerously-skip-permissions',
|
||||
'--dangerously-skip-permissions --model Opus',
|
||||
'--model Opus --dangerously-skip-permissions'
|
||||
]) {
|
||||
expect(
|
||||
claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude } }),
|
||||
claude
|
||||
).toBe('bypassPermissions')
|
||||
}
|
||||
})
|
||||
|
||||
// Manual is stored as an empty string, which owns the key and so beats the shipped default.
|
||||
it('prompts when Manual cleared the flag', () => {
|
||||
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '' } })).toBe(
|
||||
'default'
|
||||
)
|
||||
})
|
||||
|
||||
it('prompts when the user replaced the flag with something else', () => {
|
||||
expect(
|
||||
claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '--model Opus' } })
|
||||
).toBe('default')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults'
|
||||
|
||||
/**
|
||||
* The Agent Permissions setting as the SDK's own permission mode.
|
||||
*
|
||||
* Read per acquisition — like the environment overlay and the auth policy beside it — rather than
|
||||
* latched into the session record: the setting is the one copy of this fact, so nothing can
|
||||
* disagree with it and a failed restore cannot silently downgrade a session to prompting.
|
||||
*
|
||||
* Yolo still stores itself as the agent's bypass flag inside the launch arguments, which is also
|
||||
* what a terminal launch acts on, so presence of that flag is the fact to read — resolved through
|
||||
* the same default fallback the terminal uses, which is why an untouched profile bypasses. The
|
||||
* rest of the arguments string is a terminal concern this path does not interpret.
|
||||
*/
|
||||
export function claudeStructuredPermissionModeForSettings(
|
||||
settings: Partial<Pick<GlobalSettings, 'agentDefaultArgs'>> | null | undefined
|
||||
): PermissionMode {
|
||||
return resolvedTuiAgentArgsBypassPermissions('claude', settings?.agentDefaultArgs)
|
||||
? 'bypassPermissions'
|
||||
: 'default'
|
||||
}
|
||||
@@ -110,17 +110,14 @@ export async function cancelClaudeStructuredTurn(input: {
|
||||
session.prompts.releaseClaim(claim)
|
||||
return { cancelled: false }
|
||||
}
|
||||
// The translator owns turn identity. A session with no journal has published no
|
||||
// turn row for a client to name, so it holds no identity this request can contradict.
|
||||
// Judge against the published journal, because that is the only turn a client could have been
|
||||
// shown — but only while it HAS an answer. The journal drains through a serialized async queue,
|
||||
// so a null read means the row has not landed yet, not that nothing is running; falling back to
|
||||
// the in-memory turn there keeps Stop from being gated on bookkeeping. No live turn either way
|
||||
// means nothing has published an identity this request can contradict.
|
||||
const ownsRequestedTurn = (): boolean => {
|
||||
const translator = session.translator
|
||||
if (!translator) {
|
||||
return session.dispatchSequence === 0
|
||||
}
|
||||
const currentTurnId = translator.currentTurnId
|
||||
return currentTurnId === null
|
||||
? session.dispatchSequence === 0
|
||||
: currentTurnId === request.turnId
|
||||
const liveTurnId = request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null
|
||||
return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === request.turnId
|
||||
}
|
||||
// The host supplies the durable latest submission; direct adapter callers fall back to
|
||||
// the current in-memory waiter so an unknown dispatch remains fenced without a latch.
|
||||
|
||||
@@ -90,6 +90,33 @@ function providerOutput(connection: FakeConnection, uuid: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** A session whose in-memory turn is `turnId`, standing in for the adapter's own read. */
|
||||
function sessionHoldingTurn(turnId: string | null): ReturnType<typeof sessionFor> {
|
||||
const session = sessionFor()
|
||||
session.dispatchSequence = 1
|
||||
session.translator = {
|
||||
handle: vi.fn(),
|
||||
journalPrompts: { cancel: vi.fn(), resolve: vi.fn() },
|
||||
currentTurnId: turnId,
|
||||
flush: vi.fn(),
|
||||
pendingStreamedBlocks: 0,
|
||||
dispose: vi.fn()
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function cancellationOf(
|
||||
session: ReturnType<typeof sessionFor>,
|
||||
request: Parameters<typeof cancelClaudeStructuredTurn>[0]['request']
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
return cancelClaudeStructuredTurn({
|
||||
request,
|
||||
sessions: new Map([['session-1', session]]),
|
||||
compactions: new StructuredSessionCompaction(),
|
||||
admitPromptCancellation: () => true
|
||||
})
|
||||
}
|
||||
|
||||
describe('Claude turn ownership', () => {
|
||||
it('stops a turn the provider opened after the session already dispatched once', async () => {
|
||||
const claude = fakeClaude({ replayUuid: 'echo-turn' })
|
||||
@@ -349,6 +376,87 @@ describe('Claude turn ownership', () => {
|
||||
expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true)
|
||||
})
|
||||
|
||||
// The sink drains asynchronously, so the adapter's own turn can already name a row no client
|
||||
// has been shown. The published journal is what a Stop is derived from, so it is what judges it.
|
||||
it('admits a Stop for the published turn while the adapter already holds an undrained one', async () => {
|
||||
const session = sessionHoldingTurn('turn-undrained')
|
||||
const interrupt = vi.fn().mockResolvedValue(undefined)
|
||||
session.connection.interrupt = interrupt
|
||||
|
||||
await expect(
|
||||
cancellationOf(session, {
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-shown',
|
||||
fence: 1,
|
||||
resolveLiveTurnId: () => 'turn-shown'
|
||||
})
|
||||
).resolves.toEqual({ cancelled: true })
|
||||
expect(interrupt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// The journal drains through a serialized async queue, so a live turn routinely has no published
|
||||
// row yet. Refusing there would gate a user's Stop on bookkeeping, so the in-memory turn covers
|
||||
// the lag — the journal is authoritative only while it has an answer.
|
||||
it('admits a Stop for the live turn while the journal has not drained its row', async () => {
|
||||
const session = sessionHoldingTurn('turn-live')
|
||||
const interrupt = vi.fn().mockResolvedValue(undefined)
|
||||
session.connection.interrupt = interrupt
|
||||
|
||||
await expect(
|
||||
cancellationOf(session, {
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-live',
|
||||
fence: 1,
|
||||
resolveLiveTurnId: () => null
|
||||
})
|
||||
).resolves.toEqual({ cancelled: true })
|
||||
expect(interrupt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses a Stop the adapter still holds once the journal published a newer turn', async () => {
|
||||
const session = sessionHoldingTurn('turn-stale')
|
||||
const interrupt = vi.fn().mockResolvedValue(undefined)
|
||||
session.connection.interrupt = interrupt
|
||||
|
||||
await expect(
|
||||
cancellationOf(session, {
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-stale',
|
||||
fence: 1,
|
||||
resolveLiveTurnId: () => 'turn-newer'
|
||||
})
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
expect(interrupt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The guard re-checks after the delivery fence may have waited seconds, so the journal read
|
||||
// has to happen then — a value captured at request time would interrupt whatever ran next.
|
||||
it('re-reads the published turn after the delivery fence waits', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let publishedTurnId = 'turn-shown'
|
||||
const session = sessionHoldingTurn('turn-shown')
|
||||
const interrupt = vi.fn().mockResolvedValue(undefined)
|
||||
session.connection.interrupt = interrupt
|
||||
|
||||
const cancellation = cancellationOf(session, {
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-shown',
|
||||
fence: 1,
|
||||
dispatchStatus: { state: 'unknown', recovered: false },
|
||||
resolveLiveTurnId: () => publishedTurnId
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS - 1)
|
||||
publishedTurnId = 'turn-next'
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(cancellation).resolves.toEqual({ cancelled: false })
|
||||
expect(interrupt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a stale turn id once the provider opened a newer turn', async () => {
|
||||
const claude = fakeClaude({ replayUuid: 'echo-turn' })
|
||||
const { adapter, bodies, connection } = await acquiredWithJournal(claude)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getSharedManagedScriptPath,
|
||||
buildWindowsHookPowerShellCommand,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
WINDOWS_CMD_SAFE_PATH,
|
||||
writeHooksJson,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
@@ -70,9 +71,13 @@ export function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsCmdHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
if (process.platform !== 'win32') {
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
// Codex's default native Windows hook host is PowerShell; reuse it to avoid a second interpreter.
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath)
|
||||
? scriptPath
|
||||
: buildWindowsHookPowerShellCommand(scriptPath)
|
||||
}
|
||||
|
||||
export type CodexManagedHookInstallMaterial = {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCodexStructuredAppServerArgs } from './codex-structured-app-server-args'
|
||||
|
||||
describe('structured Codex app-server arguments', () => {
|
||||
it('keeps configuration flags and converts effort to the app-server config contract', () => {
|
||||
expect(
|
||||
resolveCodexStructuredAppServerArgs(
|
||||
'--profile review -c approval_policy=never --model gpt-5.6 --effort high --search',
|
||||
'posix'
|
||||
)
|
||||
).toEqual([
|
||||
'--profile',
|
||||
'review',
|
||||
'-c',
|
||||
'approval_policy=never',
|
||||
'--model',
|
||||
'gpt-5.6',
|
||||
'-c',
|
||||
'model_reasoning_effort=high',
|
||||
'--search'
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['--no-alt-screen', '--remote ws://host', '-C /tmp/elsewhere', 'resume thread-1'])(
|
||||
'reports an incompatible configured argument instead of dropping %s',
|
||||
(configured) => {
|
||||
expect(() => resolveCodexStructuredAppServerArgs(configured, 'posix')).toThrow(
|
||||
/cannot apply the configured CLI arguments.*Settings or use terminal view/
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
tokenizeStartupCommand,
|
||||
type AgentStartupShell
|
||||
} from '../../shared/tui-agent-startup-shell'
|
||||
|
||||
const VALUE_FLAGS = new Set([
|
||||
'-a',
|
||||
'--add-dir',
|
||||
'--ask-for-approval',
|
||||
'-c',
|
||||
'--config',
|
||||
'--disable',
|
||||
'--effort',
|
||||
'--enable',
|
||||
'--local-provider',
|
||||
'-m',
|
||||
'--model',
|
||||
'-p',
|
||||
'--profile',
|
||||
'--reasoning-effort',
|
||||
'-s',
|
||||
'--sandbox'
|
||||
])
|
||||
|
||||
const BOOLEAN_FLAGS = new Set([
|
||||
'--approve-for-me',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--dangerously-bypass-hook-trust',
|
||||
'--oss',
|
||||
'--search',
|
||||
'--strict-config'
|
||||
])
|
||||
|
||||
const EFFORT_FLAGS = new Set(['--effort', '--reasoning-effort'])
|
||||
|
||||
function configuredArgsError(detail: string): Error {
|
||||
return new Error(
|
||||
`Structured Codex chat cannot apply the configured CLI arguments to app-server: ${detail}. Update Codex CLI arguments in Settings or use terminal view.`
|
||||
)
|
||||
}
|
||||
|
||||
function splitOption(token: string): { flag: string; inlineValue?: string } {
|
||||
const separator = token.indexOf('=')
|
||||
return separator > 0
|
||||
? { flag: token.slice(0, separator), inlineValue: token.slice(separator + 1) }
|
||||
: { flag: token }
|
||||
}
|
||||
|
||||
/** Keeps config-affecting Codex flags and refuses every TUI-only or unknown token visibly. */
|
||||
export function resolveCodexStructuredAppServerArgs(
|
||||
configuredArgs: string,
|
||||
shell: AgentStartupShell
|
||||
): string[] {
|
||||
const parsed = tokenizeStartupCommand(configuredArgs.trim(), shell)
|
||||
if (!parsed.ok) {
|
||||
throw configuredArgsError(parsed.error)
|
||||
}
|
||||
const divergent = parsed.spans.find((span) => span.divergesFromShell)
|
||||
if (divergent) {
|
||||
throw configuredArgsError(configuredArgs.slice(divergent.start, divergent.end))
|
||||
}
|
||||
const result: string[] = []
|
||||
for (let index = 0; index < parsed.tokens.length; index += 1) {
|
||||
const token = parsed.tokens[index]
|
||||
const { flag, inlineValue } = splitOption(token)
|
||||
if (BOOLEAN_FLAGS.has(flag) && inlineValue === undefined) {
|
||||
result.push(flag)
|
||||
continue
|
||||
}
|
||||
if (!VALUE_FLAGS.has(flag)) {
|
||||
throw configuredArgsError(token || 'an empty positional argument')
|
||||
}
|
||||
const value = inlineValue ?? parsed.tokens[++index]
|
||||
if (value === undefined || value.length === 0) {
|
||||
throw configuredArgsError(`${flag} requires a value`)
|
||||
}
|
||||
if (EFFORT_FLAGS.has(flag)) {
|
||||
result.push('-c', `model_reasoning_effort=${value}`)
|
||||
} else {
|
||||
result.push(flag, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
|
||||
import { codexStructuredPermissionArgsForSettings } from './codex-structured-permission-mode'
|
||||
|
||||
const SESSION_ID = 'session-1'
|
||||
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
||||
@@ -38,14 +39,16 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
|
||||
function resolverFor(
|
||||
value: AgentSessionRecord | null,
|
||||
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
|
||||
resolveRollout: () => Promise<string | null> = async () => null
|
||||
resolveRollout: () => Promise<string | null> = async () => null,
|
||||
agentDefaultArgs: Record<string, string> = { codex: '' }
|
||||
) {
|
||||
return createCodexStructuredLaunchResolver({
|
||||
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath,
|
||||
resolveCommand: () => '/usr/local/bin/codex',
|
||||
resolveRollout,
|
||||
isWindowsProcessStartTimeAvailable: () => true
|
||||
isWindowsProcessStartTimeAvailable: () => true,
|
||||
resolvePermissionArgs: () => codexStructuredPermissionArgsForSettings({ agentDefaultArgs })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,18 +112,36 @@ describe('codex structured launch resolution', () => {
|
||||
expect(launch.resumeThreadId).toBe('thread-current')
|
||||
})
|
||||
|
||||
it('places the durable user configuration before the app-server subcommand', async () => {
|
||||
// Agent Permissions is the only thing from the arguments field that reaches app-server, and it
|
||||
// keeps the position the durable arguments used to hold: before the subcommand.
|
||||
it('places the permission flag before the app-server subcommand', async () => {
|
||||
const launch = await resolverFor(record(), undefined, undefined, {
|
||||
codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol'
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.args).toEqual(['--dangerously-bypass-approvals-and-sandbox', 'app-server'])
|
||||
})
|
||||
|
||||
it('bypasses approvals for a profile that never opened Agent settings', async () => {
|
||||
const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.args).toEqual(['--dangerously-bypass-approvals-and-sandbox', 'app-server'])
|
||||
})
|
||||
|
||||
it('leaves the approval prompts on under Manual', async () => {
|
||||
const launch = await resolverFor(record())({ identity: IDENTITY })
|
||||
|
||||
expect(launch.args).toEqual(['app-server'])
|
||||
})
|
||||
|
||||
// The configured CLI arguments are a terminal concern: a durable record written before they
|
||||
// stopped being read must not smuggle one back into app-server's argv.
|
||||
it("ignores the record's durable launch arguments", async () => {
|
||||
const launch = await resolverFor(
|
||||
record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] })
|
||||
)({ identity: IDENTITY })
|
||||
|
||||
expect(launch.args).toEqual([
|
||||
'--profile',
|
||||
'review',
|
||||
'-c',
|
||||
'model_reasoning_effort=high',
|
||||
'app-server'
|
||||
])
|
||||
expect(launch.args).toEqual(['app-server'])
|
||||
})
|
||||
|
||||
it('pins resume to the rollout file that proved the durable thread', async () => {
|
||||
|
||||
@@ -27,6 +27,9 @@ export type CodexStructuredLaunchResolverDeps = {
|
||||
resolveRollout?: typeof resolvePinnedCodexRolloutProof
|
||||
/** Test seam for the host capability; production uses the native process table. */
|
||||
isWindowsProcessStartTimeAvailable?: () => boolean
|
||||
/** The user's Agent Permissions setting as app-server argv, re-read per acquisition.
|
||||
* Absent means the CLI's own approval prompts stay on. */
|
||||
resolvePermissionArgs?: () => string[]
|
||||
}
|
||||
|
||||
export function createCodexStructuredLaunchResolver(
|
||||
@@ -66,7 +69,9 @@ export function createCodexStructuredLaunchResolver(
|
||||
pathEnv,
|
||||
...(homePath ? { homePath } : {})
|
||||
})
|
||||
const args = [...(record.launchArgs ?? []), 'app-server']
|
||||
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
|
||||
// concern, and the permission posture they used to smuggle in is derived per acquisition.
|
||||
const args = [...(deps.resolvePermissionArgs?.() ?? []), 'app-server']
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
|
||||
const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { codexStructuredPermissionArgsForSettings } from './codex-structured-permission-mode'
|
||||
|
||||
const BYPASS = ['--dangerously-bypass-approvals-and-sandbox']
|
||||
|
||||
describe('codexStructuredPermissionArgsForSettings', () => {
|
||||
it('bypasses when the user has never opened Agent settings', () => {
|
||||
expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: {} })).toEqual(BYPASS)
|
||||
expect(codexStructuredPermissionArgsForSettings({})).toEqual(BYPASS)
|
||||
expect(codexStructuredPermissionArgsForSettings(null)).toEqual(BYPASS)
|
||||
expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { claude: '' } })).toEqual(
|
||||
BYPASS
|
||||
)
|
||||
})
|
||||
|
||||
it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => {
|
||||
for (const codex of [
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol',
|
||||
'--model gpt-5.6-sol --dangerously-bypass-approvals-and-sandbox'
|
||||
]) {
|
||||
expect(
|
||||
codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { codex } }),
|
||||
codex
|
||||
).toEqual(BYPASS)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the approval prompts on when Manual cleared the flag', () => {
|
||||
expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { codex: '' } })).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
// The passthrough that used to carry these to app-server is gone on purpose; only the
|
||||
// permission posture is derived, and nothing else from the field reaches argv.
|
||||
it('carries nothing but the permission posture out of the arguments field', () => {
|
||||
expect(
|
||||
codexStructuredPermissionArgsForSettings({
|
||||
agentDefaultArgs: {
|
||||
codex: '--profile review --add-dir /repo -c model_reasoning_effort=high'
|
||||
}
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults'
|
||||
import { YOLO_TUI_AGENT_ARGS } from '../../shared/tui-agent-permissions'
|
||||
|
||||
/**
|
||||
* The Agent Permissions setting as app-server argv.
|
||||
*
|
||||
* Derived per acquisition from the resolved launch arguments, never from the free-text Arguments
|
||||
* field: app-server takes a narrower option set than the interactive CLI and the two are versioned
|
||||
* apart, so the only thing read out of that field is the posture the toggle stores in it. An
|
||||
* untouched profile resolves to the default Orca ships, which is the bypass flag.
|
||||
*/
|
||||
export function codexStructuredPermissionArgsForSettings(
|
||||
settings: Partial<Pick<GlobalSettings, 'agentDefaultArgs'>> | null | undefined
|
||||
): string[] {
|
||||
const bypassArg = YOLO_TUI_AGENT_ARGS.codex
|
||||
return bypassArg !== undefined &&
|
||||
resolvedTuiAgentArgsBypassPermissions('codex', settings?.agentDefaultArgs)
|
||||
? [bypassArg]
|
||||
: []
|
||||
}
|
||||
@@ -28,11 +28,9 @@ vi.mock('os', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { CodexHookService } from './hook-service'
|
||||
import { buildWindowsHookPowerShellCommand } from '../agent-hooks/installer-utils'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/
|
||||
|
||||
const homes = setupCodexHookHomes(homedirMock, getPathMock)
|
||||
|
||||
function localManagedCodexEvents(): string[] {
|
||||
@@ -184,10 +182,7 @@ describe('CodexHookService', () => {
|
||||
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
|
||||
// be written verbatim as the hook command, so Codex split it at the space and
|
||||
// the hook exited with code 1. Keep spaced paths on the encoded launcher so
|
||||
// `cmd.exe /C` never sees the raw script path.
|
||||
// #6078: the existing PowerShell host must still quote spaced profile paths.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command when the profile path contains a space (#6078)',
|
||||
async () => {
|
||||
@@ -208,7 +203,11 @@ describe('CodexHookService', () => {
|
||||
|
||||
for (const eventName of localManagedCodexEvents()) {
|
||||
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
expect(command).toBe(
|
||||
buildWindowsHookPowerShellCommand(
|
||||
join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
@@ -216,10 +215,9 @@ describe('CodexHookService', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: cmd.exe expands `%` and treats `^` as an escape even inside otherwise
|
||||
// plausible paths. Keep those rare cases on the encoded launcher from #6078.
|
||||
// Preserve literal-path quoting when constructing commands for shell metacharacters.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'keeps the encoded launcher when the profile path contains cmd metacharacters',
|
||||
'quotes the script path when the profile contains cmd metacharacters',
|
||||
async () => {
|
||||
const metacharHome = join(tmpdir(), 'orca %ORCA_TEST% ^ home')
|
||||
mkdirSync(metacharHome, { recursive: true })
|
||||
@@ -238,7 +236,11 @@ describe('CodexHookService', () => {
|
||||
|
||||
for (const eventName of localManagedCodexEvents()) {
|
||||
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
expect(command).toBe(
|
||||
buildWindowsHookPowerShellCommand(
|
||||
join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(metacharHome, { recursive: true, force: true })
|
||||
@@ -268,7 +270,11 @@ describe('CodexHookService', () => {
|
||||
expect(command).not.toMatch(/powershell/i)
|
||||
expect(command).toMatch(/\\agent-hooks\\codex-hook\.cmd$/)
|
||||
} else {
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
expect(command).toBe(
|
||||
buildWindowsHookPowerShellCommand(
|
||||
join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { createServer } from 'node:http'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import { getManagedCommand, CODEX_EVENTS } from './codex-hook-definition'
|
||||
import { getManagedScript } from './codex-hook-script'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
wrapWindowsCmdHookCommand
|
||||
} from '../agent-hooks/installer-utils'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => process.cwd() } }))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('Codex Windows hook command', () => {
|
||||
it.each(['测试用户', '홍길동', '日本語', 'rené', '测试 用户', "测试 O'Brien"])(
|
||||
'uses the existing PowerShell host for %s without a second interpreter',
|
||||
(profile) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const path = `C:\\Users\\${profile}\\.orca\\agent-hooks\\codex-hook.cmd`
|
||||
const command = getManagedCommand(path)
|
||||
expect(command).not.toMatch(/powershell\.exe|EncodedCommand|Set-ExecutionPolicy/)
|
||||
expect(command).toContain(`-LiteralPath '${path.replaceAll("'", "''")}' -PathType Leaf`)
|
||||
expect(command).toContain(`[Console]::In.ReadToEnd()`)
|
||||
expect(createManagedCommandMatcher('codex-hook.cmd')(command)).toBe(true)
|
||||
expect(wrapWindowsCmdHookCommand(path)).toContain('-EncodedCommand')
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the existing ASCII command and POSIX launcher', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const path = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
|
||||
expect(getManagedCommand(path)).toBe(path)
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
expect(getManagedCommand('/home/测试/.orca/agent-hooks/codex-hook.sh')).toContain(
|
||||
"[ -x '/home/测试/.orca/agent-hooks/codex-hook.sh' ]"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const windowsPowerShell = join(
|
||||
process.env.SystemRoot ?? 'C:\\Windows',
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe'
|
||||
)
|
||||
const windowsPwsh = (process.env.PATH ?? '')
|
||||
.split(delimiter)
|
||||
.map((directory) => join(directory, 'pwsh.exe'))
|
||||
.find((file) => existsSync(file))
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('Codex hook delivery through PowerShell', () => {
|
||||
it.each([windowsPowerShell, ...(windowsPwsh ? [windowsPwsh] : [])])(
|
||||
'delivers all eight events exactly once from a Unicode profile through %s',
|
||||
async (shell) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-cjk-'))
|
||||
const home = join(root, "测试 사용자 O'Brien")
|
||||
mkdirSync(home)
|
||||
const scriptPath = join(home, 'codex-hook.cmd')
|
||||
writeFileSync(scriptPath, getManagedScript())
|
||||
const posts: URLSearchParams[] = []
|
||||
const tokens: unknown[] = []
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
tokens.push(req.headers['x-orca-agent-hook-token'])
|
||||
posts.push(new URLSearchParams(Buffer.concat(chunks).toString('utf8')))
|
||||
res.writeHead(204).end()
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing listener port')
|
||||
}
|
||||
const env = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))
|
||||
),
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_AGENT_HOOK_PORT: String(address.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'unicode-test-token',
|
||||
ORCA_PANE_KEY: 'unicode-tab:unicode-leaf',
|
||||
ORCA_WORKTREE_ID: 'C:\\folder workspace\\测试 & repo'
|
||||
}
|
||||
const payloads = CODEX_EVENTS.map((hook_event_name) =>
|
||||
JSON.stringify({
|
||||
hook_event_name,
|
||||
prompt: '测试 한국어 😀 " \\ \n & %PATH% ! $HOME '.repeat(7000)
|
||||
})
|
||||
)
|
||||
const invoke = (command: string, input: string) =>
|
||||
runProcess({
|
||||
program: shell,
|
||||
args: ['-NoProfile', '-Command', command],
|
||||
input,
|
||||
env,
|
||||
timeoutMs: 10_000,
|
||||
terminationBarrier: true
|
||||
})
|
||||
try {
|
||||
for (let offset = 0; offset < payloads.length; offset += 4) {
|
||||
const results = await Promise.all(
|
||||
payloads
|
||||
.slice(offset, offset + 4)
|
||||
.map((payload) => invoke(getManagedCommand(scriptPath), payload))
|
||||
)
|
||||
for (const result of results) {
|
||||
expect(result).toMatchObject({ code: 0, stdout: '', stderr: '', timedOut: false })
|
||||
}
|
||||
}
|
||||
expect(posts).toHaveLength(CODEX_EVENTS.length)
|
||||
expect(tokens).toEqual(CODEX_EVENTS.map(() => 'unicode-test-token'))
|
||||
expect(posts.map((post) => post.get('payload')).sort()).toEqual([...payloads].sort())
|
||||
for (const post of posts) {
|
||||
expect(post.get('paneKey')).toBe(env.ORCA_PANE_KEY)
|
||||
expect(post.get('worktreeId')).toBe(env.ORCA_WORKTREE_ID)
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
expect(await invoke(getManagedCommand(scriptPath), payloads[0])).toMatchObject({
|
||||
code: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
rmSync(scriptPath)
|
||||
expect(await invoke(getManagedCommand(scriptPath), payloads[0])).toMatchObject({
|
||||
code: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
expect(posts).toHaveLength(CODEX_EVENTS.length)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
await removeTree(root)
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user