Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/reference/remote-wire-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/cli/handlers/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)

Expand Down Expand Up @@ -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',
Expand All @@ -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 })
}
}
Expand Down
22 changes: 20 additions & 2 deletions src/main/ai-vault-search/session-search-service-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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()
Expand Down
12 changes: 7 additions & 5 deletions src/main/ai-vault-search/session-search-service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -68,19 +65,24 @@ 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
}
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)),
Expand Down
34 changes: 34 additions & 0 deletions src/main/runtime/rpc/methods/ai-vault-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
39 changes: 17 additions & 22 deletions src/shared/ai-vault-search-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Comment thread
nwparker marked this conversation as resolved.
const status = await readSessionSearchStatus(call)
const agents = compatibleSearchAgents(parsed.filters.agents, status)
if (agents.length === 0) {
return { kind: 'unavailable', reason: 'unsupported-agent' }
Expand Down Expand Up @@ -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
}
}
Loading
Loading