From 7f71ffec3293ab2278c10dba539f063f451fbcec Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:12:36 -0700 Subject: [PATCH] fix(search): negotiate supported agents across mixed host versions Keep older request and reply parsers usable while current peers retain all supported history. --- docs/reference/remote-wire-compatibility.md | 14 ++ .../session-search-scope-entry-points.test.ts | 15 +- .../session-search-scope-service.test.ts | 18 +- .../session-search-service-registry.test.ts | 42 ++++- .../session-search-service-registry.ts | 36 ++-- src/main/ipc/ai-vault-search.test.ts | 7 +- .../rpc/methods/ai-vault-search.test.ts | 4 +- .../ai-vault-search-agent-negotiation.ts | 39 +++++ src/shared/ai-vault-search-client.ts | 24 ++- src/shared/ai-vault-search-contract.ts | 2 + .../ai-vault-search-qoder-client.test.ts | 7 +- ...oder-history-search-downgrade.unit.test.ts | 9 +- ...on-search-agent-set-downgrade.unit.test.ts | 161 ++++++++++++++++++ 13 files changed, 343 insertions(+), 35 deletions(-) create mode 100644 src/shared/ai-vault-search-agent-negotiation.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 b2bcb3c40f4e..c812fcf0e83e 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -111,6 +111,20 @@ 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 pre-Qoder/Jcode search vocabulary. The +existing `supportsQoderHistory` flag still adds Qoder for peers that understand it; +Jcode and later agents require the list. This conservatively withholds an unsupported +provider instead of rejecting the whole page or widening a sole-provider query. +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/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index 505dc3e4e2c8..a320b5c44b46 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -1,3 +1,4 @@ +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { afterEach, describe, expect, it } from 'vitest' import { AiVaultHandler } from '../../relay/ai-vault-handler' import type { RelayDispatcher } from '../../relay/dispatcher' @@ -78,7 +79,12 @@ describe('every search entry point carries the scope identity through', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await relayHandler()({ query: 'needle', supportsQoderHistory: true, within: WITHIN }) + await relayHandler()({ + query: 'needle', + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], + within: WITHIN + }) expect(service.search).toHaveBeenCalledWith(expect.anything(), { kind: 'resolved', paths: ['/work/app'] @@ -88,7 +94,12 @@ describe('every search entry point carries the scope identity through', () => { it('hands the relay’s own verdict down, that host carrying no repo catalog', async () => { const service = fakeSearchService() setSessionSearchService(service) - await relayHandler()({ query: 'needle', supportsQoderHistory: true, within: WITHIN }) + await relayHandler()({ + query: 'needle', + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], + within: WITHIN + }) expect(service.search).toHaveBeenCalledWith( { query: 'needle', limit: 20 }, { diff --git a/src/main/ai-vault-search/session-search-scope-service.test.ts b/src/main/ai-vault-search/session-search-scope-service.test.ts index 595f47baef17..cfd9e87e1eae 100644 --- a/src/main/ai-vault-search/session-search-scope-service.test.ts +++ b/src/main/ai-vault-search/session-search-scope-service.test.ts @@ -1,3 +1,4 @@ +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { afterEach, describe, expect, it } from 'vitest' import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contract' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' @@ -29,6 +30,7 @@ describe('scope identity at the search choke point', () => { { query: 'needle', supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } }, 'ipc' @@ -48,6 +50,7 @@ describe('scope identity at the search choke point', () => { { query: 'needle', supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], within: { kind: 'project', projectKey: 'repo:repo-1' } }, 'ipc' @@ -67,6 +70,7 @@ describe('scope identity at the search choke point', () => { { query: 'needle', supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], within: { kind: 'project', projectKey: 'repo:elsewhere' } }, 'ipc' @@ -89,6 +93,7 @@ describe('scope identity at the search choke point', () => { { query: 'needle', supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } }, 'ipc' @@ -117,6 +122,7 @@ describe('scope identity at the search choke point', () => { { query: 'needle', supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], within: { kind: 'project', projectKey: 'repo:repo-1' } }, 'ipc' @@ -133,7 +139,10 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await searchSessionService({ query: 'needle', supportsQoderHistory: true }, 'ipc') + await searchSessionService( + { query: 'needle', supportsQoderHistory: true, supportedAgents: [...AI_VAULT_AGENTS] }, + 'ipc' + ) expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }, undefined) }) @@ -142,7 +151,12 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', supportsQoderHistory: true, filters: { scopePaths: ['/other'] } }, + { + query: 'needle', + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS], + filters: { scopePaths: ['/other'] } + }, 'ipc' ) expect(service.search).toHaveBeenCalledWith( 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 36ca58e7d808..e47315277111 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 @@ -1,10 +1,10 @@ +import { LEGACY_SESSION_SEARCH_AGENTS } from '../../shared/ai-vault-search-agent-negotiation' import { afterEach, describe, expect, it, vi } from 'vitest' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' import { unavailableSessionSearchStatus, createSessionSearchClient } from '../../shared/ai-vault-search-client' -import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { setSessionSearchService, searchSessionService, @@ -59,7 +59,7 @@ describe('session search service registry', () => { { query: 'needle', limit: 20, - filters: { agents: AI_VAULT_AGENTS.filter((a) => a !== 'qoder') } + filters: { agents: [...LEGACY_SESSION_SEARCH_AGENTS] } }, undefined ) @@ -98,6 +98,44 @@ describe('session search service registry', () => { ) } ) + it('keeps a sole unsupported host filter narrow and removes pagination', async () => { + const service = fakeSearchService() + const page = await service.search({ query: 'proof' }) + if (page.kind !== 'results') { + throw new Error('Fixture has no result page') + } + service.search.mockResolvedValue({ + ...page, + page: { cursor: 'unreadable-next', hasMore: true } + }) + service.search.mockClear() + setSessionSearchService(service) + expect( + await searchSessionService({ query: 'proof', filters: { agents: ['jcode'] } }, 'relay') + ).toMatchObject({ hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'proof', limit: 20, filters: { agents: ['jcode'] } }, + { kind: 'resolved', paths: [''] } + ) + }) + 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 2740c34cac68..653914106be5 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -15,6 +15,7 @@ import { } from '../../shared/ai-vault-search-transport' import type { SessionSearchService } from './session-search-service' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' +import { sessionSearchAgentsForPeer } from '../../shared/ai-vault-search-agent-negotiation' let service: SessionSearchService | null = null @@ -35,32 +36,38 @@ export async function searchSessionService( // The choke point every entry point funnels through, so every host kind // resolves alike; the verdict goes to the service, which answers off and // not-ready first. - const { within, supportsQoderHistory, ...request } = parsed + const { within, supportsQoderHistory, supportedAgents, ...request } = parsed // Older clients reject the whole page when a hit has an unknown agent tag. const requestedAgents = request.filters?.agents + const peerAgents = sessionSearchAgentsForPeer(supportedAgents, supportsQoderHistory) const compatibleAgents = (requestedAgents?.length ? requestedAgents : AI_VAULT_AGENTS).filter( - (agent) => supportsQoderHistory || agent !== 'qoder' + (agent) => peerAgents.includes(agent) ) - const compatibleRequest = supportsQoderHistory - ? request - : { - ...request, - filters: { - ...request.filters, - agents: compatibleAgents.length - ? compatibleAgents - : AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') + const compatibleRequest = + compatibleAgents.length === 0 || + (!requestedAgents?.length && compatibleAgents.length === AI_VAULT_AGENTS.length) + ? request + : { + ...request, + filters: { + ...request.filters, + agents: compatibleAgents + } } - } const hostScope = within ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) : undefined + // An unkeyable scope reuses no-match retrieval after consent and readiness. + const retrievalScope = + compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' + ? ({ kind: 'resolved', paths: [''] } as const) + : 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 @@ -85,7 +92,8 @@ export async function sessionSearchServiceStatus( return redactStatusForTransport( AiVaultSearchStatusSchema.parse({ ...(service ? await service.status() : unavailableSessionSearchStatus()), - supportsQoderHistory: true + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS] }), transport ) diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index bdcf8054f24f..2290a0dd4bfd 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -1,3 +1,4 @@ +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({ @@ -86,7 +87,8 @@ describe('desktop IPC and preload search boundary', () => { expect(sshSearch).toHaveBeenCalledWith('ssh-host', 'aiVault.searchSessions', { query: 'needle', limit: 20, - supportsQoderHistory: true + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS] }) expect(result).toMatchObject({ hits: [{ executionHostId: 'ssh:ssh-host', source: { presence: 'present' } }] @@ -107,7 +109,8 @@ describe('desktop IPC and preload search boundary', () => { expect(runtimeSearch).toHaveBeenCalledWith('env-1', 'aiVault.searchSessions', { query: 'needle', limit: 20, - supportsQoderHistory: true + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS] }) expect(result).toMatchObject({ hits: [{ executionHostId: 'runtime:env-1', source: { presence: 'present' } }] 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 6f3b4301f701..59d91114dcd9 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -1,5 +1,5 @@ +import { LEGACY_SESSION_SEARCH_AGENTS } from '../../../../shared/ai-vault-search-agent-negotiation' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' import { RpcDispatcher } from '../dispatcher' import { OrcaRuntimeService } from '../../orca-runtime' import { AI_VAULT_METHODS } from './ai-vault' @@ -56,7 +56,7 @@ describe('session search runtime RPC', () => { { query: 'needle', limit: 20, - filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + filters: { agents: [...LEGACY_SESSION_SEARCH_AGENTS] } }, undefined ) diff --git a/src/shared/ai-vault-search-agent-negotiation.ts b/src/shared/ai-vault-search-agent-negotiation.ts new file mode 100644 index 000000000000..659b2d947234 --- /dev/null +++ b/src/shared/ai-vault-search-agent-negotiation.ts @@ -0,0 +1,39 @@ +import { AI_VAULT_AGENTS, type AiVaultAgent } from './ai-vault-types' + +// Frozen vocabulary for peers that predate agent-list negotiation. +export const LEGACY_SESSION_SEARCH_AGENTS = [ + 'claude', + 'codebuddy', + 'codex', + 'hermes', + 'pi', + 'omp', + 'prime-agent', + 'cursor', + 'gemini', + 'antigravity', + 'rovo', + 'copilot', + 'opencode', + 'opencode2', + 'zcode', + 'grok', + 'openclaw', + 'devin', + 'droid', + 'cline', + 'kimi', + 'muse' +] as const satisfies readonly AiVaultAgent[] + +export function sessionSearchAgentsForPeer( + supportedAgents: readonly string[] | undefined, + supportsQoderHistory: boolean | undefined +): AiVaultAgent[] { + if (supportedAgents !== undefined) { + return AI_VAULT_AGENTS.filter((agent) => supportedAgents.includes(agent)) + } + return supportsQoderHistory === true + ? [...LEGACY_SESSION_SEARCH_AGENTS, 'qoder'] + : [...LEGACY_SESSION_SEARCH_AGENTS] +} diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index 66501b2994b8..f107ec70b01a 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -1,3 +1,5 @@ +import { AI_VAULT_AGENTS } from './ai-vault-types' +import { sessionSearchAgentsForPeer } from './ai-vault-search-agent-negotiation' import { AiVaultSearchRequestSchema, AiVaultSearchResponseSchema, @@ -50,17 +52,23 @@ 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?.includes('qoder')) { + if (transport !== 'ipc' && parsed.filters?.agents?.length) { const status = AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) - if (status.supportsQoderHistory !== true) { - const agents = parsed.filters.agents.filter((agent) => agent !== 'qoder') - if (agents.length === 0) { - return { kind: 'unavailable', reason: 'unsupported-agent' } - } - hostRequest = { ...parsed, filters: { ...parsed.filters, agents } } + const supported = sessionSearchAgentsForPeer( + status.supportedAgents, + status.supportsQoderHistory + ) + const agents = parsed.filters.agents.filter((agent) => supported.includes(agent)) + if (agents.length === 0) { + return { kind: 'unavailable', reason: 'unsupported-agent' } } + hostRequest = { ...parsed, filters: { ...parsed.filters, agents } } } - raw = await call('aiVault.searchSessions', { ...hostRequest, supportsQoderHistory: true }) + raw = await call('aiVault.searchSessions', { + ...hostRequest, + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS] + }) } catch (error) { if (isUnknownSessionSearchMethod(error)) { return { kind: 'unavailable', reason: 'no-service' } diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts index 8966b1bfe771..9ec6799fc6db 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -24,6 +24,7 @@ export const AiVaultSearchRequestSchema = z cursor: z.string().optional(), filters: AiVaultSearchFiltersSchema.optional(), supportsQoderHistory: z.boolean().optional(), + supportedAgents: z.array(z.string().min(1).max(64)).max(128).optional(), /** Scope by identity, resolved into paths by whichever host answers. */ within: AiVaultSearchScopeIdentitySchema.optional(), debug: z.boolean().optional() @@ -135,6 +136,7 @@ export const AiVaultSearchStatusRequestSchema = z.object({}) export const AiVaultSetSearchEnabledParamsSchema = z.object({ enabled: z.boolean() }) export const AiVaultSearchStatusSchema = z.object({ supportsQoderHistory: z.boolean().optional(), + supportedAgents: z.array(z.string().min(1).max(64)).max(128).optional(), enabled: z.boolean(), phase: z.enum(['idle', 'indexing', 'current', 'degraded', 'closed']), filesIndexed: z.number().int().nonnegative(), diff --git a/src/shared/ai-vault-search-qoder-client.test.ts b/src/shared/ai-vault-search-qoder-client.test.ts index 0df8dd802ea7..0e3200d3379c 100644 --- a/src/shared/ai-vault-search-qoder-client.test.ts +++ b/src/shared/ai-vault-search-qoder-client.test.ts @@ -1,6 +1,7 @@ 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 { AI_VAULT_AGENTS } from './ai-vault-types' import { searchHit, searchResults } from './ai-vault-search-test-fixture' describe('Qoder search negotiation', () => { @@ -87,7 +88,8 @@ describe('Qoder search negotiation', () => { query: 'q', limit: 20, filters: { agents: ['qoder'] }, - supportsQoderHistory: true + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS] }) expect(result).toMatchObject({ kind: 'results', hits: [{ agent: 'qoder' }] }) } @@ -103,7 +105,8 @@ describe('Qoder search negotiation', () => { query: 'q', limit: 20, filters: { agents: ['qoder'] }, - supportsQoderHistory: true + supportsQoderHistory: true, + supportedAgents: [...AI_VAULT_AGENTS] }) }) 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 0a00604408d5..a6a37b6640a6 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 @@ -77,6 +77,8 @@ test.each(['v1.4.211', 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'])( !requestSchema || typeof requestSchema !== 'object' || !('parse' in requestSchema) || + !('safeParse' in requestSchema) || + typeof requestSchema.safeParse !== 'function' || typeof requestSchema.parse !== 'function' ) { throw new Error('Pinned host has no search request parser') @@ -117,7 +119,12 @@ test.each(['v1.4.211', 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'])( expect(call).toHaveBeenLastCalledWith( 'aiVault.searchSessions', expect.objectContaining({ - filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + filters: { + agents: AI_VAULT_AGENTS.filter( + (agent) => + requestSchema.safeParse({ query: 'proof', filters: { agents: [agent] } }).success + ) + } }) ) } 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 000000000000..386a865ba709 --- /dev/null +++ b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts @@ -0,0 +1,161 @@ +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() + } + } +)