From 136b990a053333a577012d9a4caaca0785a7a13d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:11 -0700 Subject: [PATCH] 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 --- docs/reference/remote-wire-compatibility.md | 17 ++ src/cli/handlers/search.test.ts | 18 +- ...session-search-agent-compatibility.test.ts | 30 ++- .../session-search-service-registry.test.ts | 22 +- .../session-search-service-registry.ts | 12 +- .../rpc/methods/ai-vault-search.test.ts | 34 +++ src/shared/ai-vault-search-client.ts | 39 ++-- .../ai-vault-search-status-fallback.test.ts | 128 +++++++++++ ...oder-history-search-downgrade.unit.test.ts | 2 +- ...on-search-agent-set-downgrade.unit.test.ts | 205 ++++++++++++++++++ 10 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 src/shared/ai-vault-search-status-fallback.test.ts create mode 100644 tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index b2bcb3c40f4..c7720c68dce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -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 diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts index c65a96ebe5c..2db6b9785fc 100644 --- a/src/cli/handlers/search.test.ts +++ b/src/cli/handlers/search.test.ts @@ -63,7 +63,17 @@ async function runSearch( ): Promise<{ call: ReturnType; 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], diff --git a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts index a8c1ba96e75..d04cf73f667 100644 --- a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts +++ b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts @@ -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 }) } } diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts index d8fb2c081bc..8e172e5f2c1 100644 --- a/src/main/ai-vault-search/session-search-service-registry.test.ts +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -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() diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index 2f339185e64..02c448e11da 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -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)), diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index 16f998e84e9..c247b0f31b1 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -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) => { diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index c00b27e6b2c..3ca5184ed1f 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -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) => Promise +): Promise { + try { + return AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + } catch (error) { + if (isUnknownSessionSearchMethod(error)) { + return unavailableSessionSearchStatus() } + throw error } } diff --git a/src/shared/ai-vault-search-status-fallback.test.ts b/src/shared/ai-vault-search-status-fallback.test.ts new file mode 100644 index 00000000000..56c9118b736 --- /dev/null +++ b/src/shared/ai-vault-search-status-fallback.test.ts @@ -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) => { + 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) => { + 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) + }) +}) diff --git a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts index c9bdc70b6b6..6204a15f8b7 100644 --- a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts @@ -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) } ) diff --git a/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts new file mode 100644 index 00000000000..71554054d0d --- /dev/null +++ b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts @@ -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) => { + 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' + ]) + } +)