mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
fix(search): negotiate supported agents across mixed host versions (#25009)
Keep older request and reply parsers usable while current peers retain all supported history. Co-authored-by: nwparker <nwparker@users.noreply.github.com>
This commit is contained in:
@@ -111,6 +111,23 @@ provider, it puts `unsupported` on the wire and makes that host refuse its own.
|
||||
reply-schema fallback must never shape a param. Gate on the token instead, where the
|
||||
client decides what it is willing to do with an arm it does not know.
|
||||
|
||||
## Session search agent negotiation
|
||||
|
||||
`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients
|
||||
send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists,
|
||||
so a future provider name does not make a peer reject the capability reply. The client
|
||||
narrows explicit agent filters to the host's list before calling its request parser.
|
||||
The host narrows retrieval to the client's list before publishing a page.
|
||||
|
||||
A peer without this field uses the frozen v1.4.211 search vocabulary. The existing
|
||||
`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support;
|
||||
`supportsJcodeHistory` independently proves Jcode support. An explicit list takes
|
||||
precedence over both flags. If only the status method is missing, the client still
|
||||
searches the conservative legacy subset; other status errors propagate. Empty host
|
||||
intersections keep the requested filters and use the existing no-match scope, so
|
||||
consent, readiness, and unknown-scope results retain their normal precedence.
|
||||
Local IPC advertises this build's full list, and every remote leg negotiates separately.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
|
||||
|
||||
@@ -63,7 +63,17 @@ async function runSearch(
|
||||
): Promise<{ call: ReturnType<typeof vi.fn>; output: string }> {
|
||||
const call = options.error
|
||||
? vi.fn().mockRejectedValue(options.error)
|
||||
: vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
|
||||
: vi
|
||||
.fn()
|
||||
.mockImplementation((method: string) =>
|
||||
Promise.resolve(
|
||||
envelope(
|
||||
method === 'aiVault.searchStatus'
|
||||
? statusResponse
|
||||
: (options.result ?? resultsResponse)
|
||||
)
|
||||
)
|
||||
)
|
||||
const lines: string[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
|
||||
lines.push(String(value))
|
||||
@@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => {
|
||||
]
|
||||
]
|
||||
|
||||
it.each(flagCases)('sends %s', async (_name, flags, params) => {
|
||||
it.each(flagCases)('sends %s', async (name, flags, params) => {
|
||||
const { call } = await runSearch(flags)
|
||||
|
||||
expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1)
|
||||
if (name === 'filters') {
|
||||
expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {})
|
||||
}
|
||||
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', {
|
||||
...params,
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
|
||||
@@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)(
|
||||
},
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'proof', limit: 20, freshness: 'wait-until-current' },
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
expect(service.reconcile).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)(
|
||||
)
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'does not query or reconcile an unsupported-only filter on %s',
|
||||
'keeps an unsupported-only filter narrow through normal service checks on %s',
|
||||
async (transport) => {
|
||||
const request = {
|
||||
query: 'proof',
|
||||
@@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)(
|
||||
{ ...request, filters: { agents: [...request.filters.agents] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).not.toHaveBeenCalled()
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
query: 'proof',
|
||||
limit: 20,
|
||||
filters: { agents: ['jcode'] },
|
||||
freshness: 'wait-until-current'
|
||||
},
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
expect(service.status).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).toHaveBeenCalledTimes(1)
|
||||
for (const reason of ['disabled', 'not-ready'] as const) {
|
||||
service.search.mockResolvedValue({ kind: 'unavailable', reason })
|
||||
expect(
|
||||
await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport)
|
||||
await searchSessionService(
|
||||
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ describe('session search service registry', () => {
|
||||
{ query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).toHaveBeenCalledTimes(1)
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledTimes(2)
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
const client = createSessionSearchClient(
|
||||
(method, request) =>
|
||||
@@ -114,6 +114,24 @@ describe('session search service registry', () => {
|
||||
)
|
||||
expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS)
|
||||
})
|
||||
it.each(['disabled', 'not-ready'] as const)(
|
||||
'preserves %s precedence for empty negotiated host intersections',
|
||||
async (reason) => {
|
||||
const service = fakeSearchService()
|
||||
service.search.mockResolvedValue({ kind: 'unavailable', reason })
|
||||
setSessionSearchService(service)
|
||||
expect(
|
||||
await searchSessionService(
|
||||
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
|
||||
'runtime'
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'proof', limit: 20, filters: { agents: ['jcode'] } },
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
}
|
||||
)
|
||||
it('waits for reconcile before search, and clears its timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const service = fakeSearchService()
|
||||
|
||||
@@ -52,11 +52,8 @@ export async function searchSessionService(
|
||||
supportsJcodeHistory
|
||||
}
|
||||
)
|
||||
if (compatibleAgents.length === 0) {
|
||||
return { kind: 'unavailable', reason: 'unsupported-agent' }
|
||||
}
|
||||
const compatibleRequest =
|
||||
compatibleAgents.length === agents.length
|
||||
compatibleAgents.length === 0 || compatibleAgents.length === agents.length
|
||||
? request
|
||||
: {
|
||||
...request,
|
||||
@@ -68,12 +65,16 @@ export async function searchSessionService(
|
||||
const hostScope = within
|
||||
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
|
||||
: undefined
|
||||
const retrievalScope =
|
||||
compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
|
||||
? { kind: 'resolved' as const, paths: [''] }
|
||||
: hostScope
|
||||
const freshness =
|
||||
request.freshness === 'wait-until-current'
|
||||
? await reconcileWithin(current, freshnessTimeoutMs)
|
||||
: false
|
||||
const result = AiVaultSearchResponseSchema.parse(
|
||||
await current.search(compatibleRequest, hostScope)
|
||||
await current.search(compatibleRequest, retrievalScope)
|
||||
)
|
||||
if (result.kind !== 'results') {
|
||||
return result
|
||||
@@ -81,6 +82,7 @@ export async function searchSessionService(
|
||||
const { debug, ...fields } = result
|
||||
return {
|
||||
...fields,
|
||||
...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}),
|
||||
hits: result.hits
|
||||
.filter((hit) => compatibleAgents.includes(hit.agent))
|
||||
.map((hit) => redactForTransport(hit, transport)),
|
||||
|
||||
@@ -116,6 +116,40 @@ describe('session search runtime RPC', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
it.each(['runtime', 'relay'] as const)(
|
||||
'searches when the real dispatcher is missing only status over %s',
|
||||
async (transport) => {
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
const rpc = new RpcDispatcher({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
methods: AI_VAULT_METHODS.filter((method) => method.name !== 'aiVault.searchStatus')
|
||||
})
|
||||
const replies: unknown[] = []
|
||||
const client = createSessionSearchClient(async (method, params) => {
|
||||
const response = await rpc.dispatch({ ...request(params), method })
|
||||
replies.push(response)
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(response.error.message), { code: response.error.code })
|
||||
}
|
||||
return response.result
|
||||
}, transport)
|
||||
expect(
|
||||
await client.searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['claude'] }
|
||||
})
|
||||
).toMatchObject({ kind: 'results' })
|
||||
expect(replies).toMatchObject([
|
||||
{ ok: false, error: { code: 'method_not_found' } },
|
||||
{ ok: true }
|
||||
])
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'needle', limit: 20, filters: { agents: ['claude'] } },
|
||||
undefined
|
||||
)
|
||||
}
|
||||
)
|
||||
it('maps the old runtime dispatcher refusal and rejects malformed responses', async () => {
|
||||
const legacy = dispatcher(true)
|
||||
const client = createSessionSearchClient(async (method, params) => {
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
redactStatusForTransport,
|
||||
type SessionSearchTransport
|
||||
} from './ai-vault-search-transport'
|
||||
import {
|
||||
compatibleSearchAgents,
|
||||
needsSearchAgentNegotiation
|
||||
} from './ai-vault-search-agent-compatibility'
|
||||
import { compatibleSearchAgents } from './ai-vault-search-agent-compatibility'
|
||||
import { AI_VAULT_AGENTS } from './ai-vault-types'
|
||||
|
||||
export function unavailableSessionSearchStatus(): AiVaultSearchStatus {
|
||||
@@ -55,12 +52,8 @@ export function createSessionSearchClient(
|
||||
let raw: unknown
|
||||
try {
|
||||
// IPC and its all-hosts merge are this build; each remote leg negotiates its own host.
|
||||
if (
|
||||
transport !== 'ipc' &&
|
||||
parsed.filters?.agents &&
|
||||
needsSearchAgentNegotiation(parsed.filters.agents)
|
||||
) {
|
||||
const status = AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {}))
|
||||
if (transport !== 'ipc' && parsed.filters?.agents?.length) {
|
||||
const status = await readSessionSearchStatus(call)
|
||||
const agents = compatibleSearchAgents(parsed.filters.agents, status)
|
||||
if (agents.length === 0) {
|
||||
return { kind: 'unavailable', reason: 'unsupported-agent' }
|
||||
@@ -90,18 +83,20 @@ export function createSessionSearchClient(
|
||||
...(parsed.debug && debug ? { debug } : {})
|
||||
}
|
||||
},
|
||||
searchStatus: async () => {
|
||||
try {
|
||||
return redactStatusForTransport(
|
||||
AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})),
|
||||
transport
|
||||
)
|
||||
} catch (error) {
|
||||
if (isUnknownSessionSearchMethod(error)) {
|
||||
return unavailableSessionSearchStatus()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
searchStatus: async () =>
|
||||
redactStatusForTransport(await readSessionSearchStatus(call), transport)
|
||||
}
|
||||
}
|
||||
|
||||
async function readSessionSearchStatus(
|
||||
call: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
): Promise<AiVaultSearchStatus> {
|
||||
try {
|
||||
return AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {}))
|
||||
} catch (error) {
|
||||
if (isUnknownSessionSearchMethod(error)) {
|
||||
return unavailableSessionSearchStatus()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSessionSearchClient, unavailableSessionSearchStatus } from './ai-vault-search-client'
|
||||
import { AiVaultSearchRequestSchema as LegacyRequestSchema } from './__fixtures__/pre-qoder-search-request'
|
||||
import { searchResults } from './ai-vault-search-test-fixture'
|
||||
|
||||
const query = {
|
||||
query: 'needle',
|
||||
scope: 'conversation' as const,
|
||||
limit: 7,
|
||||
cursor: 'next-page',
|
||||
filters: { agents: ['claude'] as const, scopePaths: ['/execution-host/folder'] }
|
||||
}
|
||||
|
||||
describe.each(['runtime', 'relay'] as const)('missing search status over %s', (transport) => {
|
||||
it.each([-32601, 'method_not_found'])(
|
||||
'searches legacy filters after status refusal %s',
|
||||
async (code) => {
|
||||
const call = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === 'aiVault.searchStatus') {
|
||||
throw { code }
|
||||
}
|
||||
expect(LegacyRequestSchema.parse(params)).toEqual({
|
||||
...query,
|
||||
filters: { ...query.filters, agents: ['claude'] }
|
||||
})
|
||||
return searchResults()
|
||||
})
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
...query,
|
||||
filters: { ...query.filters, agents: ['claude'] }
|
||||
})
|
||||
).toMatchObject({ kind: 'results' })
|
||||
expect(call.mock.calls.map(([method]) => method)).toEqual([
|
||||
'aiVault.searchStatus',
|
||||
'aiVault.searchSessions'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps only the conservative legacy subset when status is absent', async () => {
|
||||
const call = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === 'aiVault.searchStatus') {
|
||||
throw { code: 'method_not_found' }
|
||||
}
|
||||
expect(LegacyRequestSchema.parse(params)).toEqual({
|
||||
...query,
|
||||
filters: { ...query.filters, agents: ['claude'] }
|
||||
})
|
||||
return searchResults()
|
||||
})
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
...query,
|
||||
filters: { ...query.filters, agents: ['jcode', 'qoder', 'codebuddy', 'zcode', 'claude'] }
|
||||
})
|
||||
).toMatchObject({ kind: 'results' })
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not dispatch an unsupported-only filter after status refusal', async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw { code: 'method_not_found' }
|
||||
})
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['jcode'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
})
|
||||
|
||||
it.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])(
|
||||
'honors explicit narrow host vocabulary $supportedAgents',
|
||||
async ({ supportedAgents }) => {
|
||||
const call = vi.fn(async () => ({ ...unavailableSessionSearchStatus(), supportedAgents }))
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['claude'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ code: 'unauthorized', message: 'Authentication required' },
|
||||
{ code: 'connection_closed', message: 'Host disconnected' },
|
||||
{ code: -32000, message: 'Execution host refused request' }
|
||||
])('propagates status failure $code without a search', async (error) => {
|
||||
const call = vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
await expect(
|
||||
createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['claude'] }
|
||||
})
|
||||
).rejects.toEqual(error)
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
})
|
||||
|
||||
it('propagates a malformed status instead of assuming a legacy host', async () => {
|
||||
const call = vi.fn(async () => ({ supportedAgents: ['claude'] }))
|
||||
await expect(
|
||||
createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['claude'] }
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
})
|
||||
|
||||
it('reports no service only when the search method itself is missing', async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw { code: 'method_not_found' }
|
||||
})
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['claude'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'no-service' })
|
||||
expect(call.mock.calls.map(() => true)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -180,7 +180,7 @@ test.each(PRE_QODER_HOSTS)(
|
||||
expect(
|
||||
await client.searchSessions({ query: 'proof', filters: { agents: ['codex'] }, within })
|
||||
).toMatchObject({ hits: [{ agent: 'codex' }] })
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createSessionSearchService } from '../../../src/main/ai-vault-search/session-search-service'
|
||||
import {
|
||||
addSyntheticSession,
|
||||
openSessionSearchHarness
|
||||
} from '../../../src/main/ai-vault-search/session-search-engine-test-fixture'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import {
|
||||
createSessionSearchClient,
|
||||
unavailableSessionSearchStatus
|
||||
} from '../../../src/shared/ai-vault-search-client'
|
||||
import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
searchHit,
|
||||
searchResults,
|
||||
fakeSearchService
|
||||
} from '../../../src/shared/ai-vault-search-test-fixture'
|
||||
import {
|
||||
searchSessionService,
|
||||
sessionSearchServiceStatus,
|
||||
setSessionSearchService
|
||||
} from '../../../src/main/ai-vault-search/session-search-service-registry'
|
||||
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
|
||||
|
||||
const LEGACY_REF = 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'
|
||||
afterEach(() => setSessionSearchService(null))
|
||||
|
||||
test('the actual pre-Jcode client reads current host pages while current peers retain every provider', async () => {
|
||||
const baseline = await importReleaseCheckoutModule(
|
||||
await materializeReleaseCheckout(LEGACY_REF),
|
||||
'src/shared/ai-vault-search-contract.ts'
|
||||
)
|
||||
const oldResponse = baseline.AiVaultSearchResponseSchema
|
||||
if (
|
||||
!oldResponse ||
|
||||
typeof oldResponse !== 'object' ||
|
||||
!('safeParse' in oldResponse) ||
|
||||
typeof oldResponse.safeParse !== 'function'
|
||||
) {
|
||||
throw new Error('Pinned release has no response parser')
|
||||
}
|
||||
const hits = (['codex', 'qoder', 'jcode'] as const).map((agent) => ({ ...searchHit(), agent }))
|
||||
expect(oldResponse.safeParse({ ...searchResults(), hits: [hits[2]] })).toHaveProperty(
|
||||
'success',
|
||||
false
|
||||
)
|
||||
const service = fakeSearchService()
|
||||
service.search.mockImplementation(async (request) => ({
|
||||
...searchResults(),
|
||||
hits: hits.filter(
|
||||
(hit) => !request.filters?.agents?.length || request.filters.agents.includes(hit.agent)
|
||||
)
|
||||
}))
|
||||
setSessionSearchService(service)
|
||||
const legacy = await searchSessionService({ query: 'proof' }, 'relay')
|
||||
expect(oldResponse.safeParse(legacy)).toHaveProperty('success', true)
|
||||
expect(legacy).toMatchObject({ hits: [{ agent: 'codex' }] })
|
||||
const client = createSessionSearchClient(
|
||||
(method, params) =>
|
||||
method === 'aiVault.searchStatus'
|
||||
? sessionSearchServiceStatus(params, 'relay')
|
||||
: searchSessionService(params, 'relay'),
|
||||
'relay'
|
||||
)
|
||||
expect(await client.searchSessions({ query: 'proof' })).toMatchObject({
|
||||
hits: [{ agent: 'codex' }, { agent: 'qoder' }, { agent: 'jcode' }]
|
||||
})
|
||||
expect(await sessionSearchServiceStatus({}, 'relay')).toMatchObject({
|
||||
supportedAgents: [...AI_VAULT_AGENTS]
|
||||
})
|
||||
})
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'a new %s client refuses sole unsupported Jcode without widening the query',
|
||||
async (transport) => {
|
||||
const call = vi.fn(async () => unavailableSessionSearchStatus())
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'proof',
|
||||
filters: { agents: ['jcode'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
}
|
||||
)
|
||||
|
||||
test('agent-list negotiation takes precedence over the old flag and tolerates future advertised names', async () => {
|
||||
const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' }
|
||||
const call = vi.fn(async (method: string) =>
|
||||
method === 'aiVault.searchStatus'
|
||||
? {
|
||||
...unavailableSessionSearchStatus(),
|
||||
supportsQoderHistory: true,
|
||||
supportedAgents: ['codex', 'jcode', 'future-agent']
|
||||
}
|
||||
: searchResults()
|
||||
)
|
||||
await createSessionSearchClient(call, 'runtime').searchSessions({
|
||||
query: 'proof',
|
||||
cursor: 'page-2',
|
||||
within,
|
||||
filters: { agents: ['codex', 'qoder', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' }
|
||||
})
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'aiVault.searchSessions',
|
||||
expect.objectContaining({
|
||||
cursor: 'page-2',
|
||||
within,
|
||||
filters: { agents: ['codex', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' },
|
||||
supportedAgents: [...AI_VAULT_AGENTS]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('a current host honors a negotiated client subset before index search and keeps scope identity', async () => {
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
await searchSessionService(
|
||||
{
|
||||
query: 'proof',
|
||||
supportsQoderHistory: true,
|
||||
supportedAgents: ['codex', 'future-agent'],
|
||||
filters: { agents: ['codex', 'qoder', 'jcode'], scopePaths: ['/task-owned'] }
|
||||
},
|
||||
'runtime'
|
||||
)
|
||||
expect(service.search).toHaveBeenLastCalledWith(
|
||||
{ query: 'proof', limit: 20, filters: { agents: ['codex'], scopePaths: ['/task-owned'] } },
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])(
|
||||
'a client with no shared advertised agents returns no indexed rows for %j',
|
||||
async ({ supportedAgents }) => {
|
||||
const harness = await openSessionSearchHarness('ss-empty-peer-agents')
|
||||
try {
|
||||
addSyntheticSession(harness.db, { id: 1, agent: 'codex' })
|
||||
addSyntheticSession(harness.db, { id: 2, agent: 'jcode' })
|
||||
const retrieve = vi.spyOn(harness.engine, 'search')
|
||||
const service = createSessionSearchService({
|
||||
engine: harness.engine,
|
||||
indexer: { status: unavailableSessionSearchStatus, reconcile: async () => {} }
|
||||
})
|
||||
setSessionSearchService(service)
|
||||
const reply = await searchSessionService({ query: 'needle', supportedAgents }, 'runtime')
|
||||
expect(reply).toMatchObject({ hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(retrieve).toHaveBeenCalledExactlyOnceWith({
|
||||
query: 'needle',
|
||||
limit: 20,
|
||||
filters: { scopePaths: [''] }
|
||||
})
|
||||
expect(retrieve.mock.results[0]).toMatchObject({
|
||||
type: 'return',
|
||||
value: { hits: [], page: { cursor: null, hasMore: false } }
|
||||
})
|
||||
} finally {
|
||||
setSessionSearchService(null)
|
||||
await harness.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['v1.4.211', LEGACY_REF])(
|
||||
'a missing status method still searches through the actual %s request parser',
|
||||
async (ref) => {
|
||||
const baseline = await importReleaseCheckoutModule(
|
||||
await materializeReleaseCheckout(ref),
|
||||
'src/shared/ai-vault-search-contract.ts'
|
||||
)
|
||||
const parser = baseline.AiVaultSearchRequestSchema
|
||||
if (
|
||||
!parser ||
|
||||
typeof parser !== 'object' ||
|
||||
!('parse' in parser) ||
|
||||
typeof parser.parse !== 'function'
|
||||
) {
|
||||
throw new Error('Pinned release has no request parser')
|
||||
}
|
||||
const call = vi.fn(async (method: string, request: Record<string, unknown>) => {
|
||||
if (method === 'aiVault.searchStatus') {
|
||||
throw { code: 'method_not_found' }
|
||||
}
|
||||
parser.parse(request)
|
||||
expect(request).toMatchObject({
|
||||
filters: {
|
||||
agents: AI_VAULT_AGENTS.filter(
|
||||
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent)
|
||||
),
|
||||
scopePaths: ['/execution-host/folder']
|
||||
}
|
||||
})
|
||||
return searchResults()
|
||||
})
|
||||
expect(
|
||||
await createSessionSearchClient(call, 'relay').searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: [...AI_VAULT_AGENTS], scopePaths: ['/execution-host/folder'] }
|
||||
})
|
||||
).toMatchObject({ kind: 'results' })
|
||||
expect(call.mock.calls.map(([method]) => method)).toEqual([
|
||||
'aiVault.searchStatus',
|
||||
'aiVault.searchSessions'
|
||||
])
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user