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:
Neil
2026-10-03 22:03:11 -07:00
committed by GitHub
co-authored by nwparker
parent 3284b4c70c
commit 136b990a05
10 changed files with 467 additions and 40 deletions
@@ -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
+16 -2
View File
@@ -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) => {
+17 -22
View File
@@ -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'
])
}
)