diff --git a/src/cli/agent-session-search-format.ts b/src/cli/agent-session-search-format.ts index ddcebaad94f..ebefb6ff7af 100644 --- a/src/cli/agent-session-search-format.ts +++ b/src/cli/agent-session-search-format.ts @@ -77,11 +77,14 @@ function formatDebug(debug: SessionSearchResults['debug']): string[] { } function formatUnavailable( - reason: 'disabled' | 'not-ready' | 'no-service' | 'scope-unknown' + reason: 'disabled' | 'not-ready' | 'no-service' | 'scope-unknown' | 'unsupported-agent' ): string { if (reason === 'disabled') { return 'Session search is off on this host.' } + if (reason === 'unsupported-agent') { + return 'This host does not support history search for the selected agent. Update Orca on that host or select another agent.' + } // The CLI scopes with --path, never with an identity, so this only reaches a // caller that built a request by hand. if (reason === 'scope-unknown') { diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts index 4df1d1ef8c9..e970d08ac37 100644 --- a/src/cli/handlers/search.test.ts +++ b/src/cli/handlers/search.test.ts @@ -102,7 +102,11 @@ describe('orca search over the runtime RPC', () => { const { call } = await runSearch([['query', 'resize race']]) expect(call).toHaveBeenCalledTimes(1) - expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { query: 'resize race', limit: 20 }) + expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { + query: 'resize race', + limit: 20, + supportsQoderHistory: true + }) }) const flagCases: [string, CliFlags, Record][] = [ @@ -157,7 +161,10 @@ describe('orca search over the runtime RPC', () => { it.each(flagCases)('sends %s', async (_name, flags, params) => { const { call } = await runSearch(flags) - expect(call).toHaveBeenCalledWith('aiVault.searchSessions', params) + expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { + ...params, + supportsQoderHistory: true + }) }) it('calls the status RPC for --index-status', async () => { diff --git a/src/cli/search-command-arguments.test.ts b/src/cli/search-command-arguments.test.ts index 27b06286f52..4e9a7fa8805 100644 --- a/src/cli/search-command-arguments.test.ts +++ b/src/cli/search-command-arguments.test.ts @@ -126,10 +126,16 @@ describe('orca search argument parsing', () => { it('rejects an unknown --agent and names the known ones', () => { expect(() => parseSearch(['search', 'q', '--agent', 'claude', '--agent', 'bogus'])).toThrow( - /Unknown --agent "bogus"\. Known agents: claude, codebuddy, codex, / + /Unknown --agent "bogus"\. Known agents: claude, codebuddy, qoder, codex, / ) }) + it('accepts Qoder without changing another agent filter', () => { + expect( + request(['search', 'q', '--agent', 'qoder', '--agent', 'codex']).filters?.agents + ).toEqual(['qoder', 'codex']) + }) + it('rejects more --path values than the contract accepts', () => { const paths = Array.from({ length: 65 }, (_, index) => ['--path', `/p${index}`]).flat() expect(() => parseSearch(['search', 'q', ...paths])).toThrow('Too many --path values (65)') 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 c1bba274e6c..505dc3e4e2c 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 @@ -78,7 +78,7 @@ describe('every search entry point carries the scope identity through', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await relayHandler()({ query: 'needle', within: WITHIN }) + await relayHandler()({ query: 'needle', supportsQoderHistory: true, within: WITHIN }) expect(service.search).toHaveBeenCalledWith(expect.anything(), { kind: 'resolved', paths: ['/work/app'] @@ -88,7 +88,7 @@ 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', within: WITHIN }) + await relayHandler()({ query: 'needle', supportsQoderHistory: true, 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 394bcae4415..595f47baef1 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 @@ -26,7 +26,11 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } + }, 'ipc' ) // Beside the request, not inside `filters.scopePaths`, which carries a wire cap. @@ -41,7 +45,11 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', within: { kind: 'project', projectKey: 'repo:repo-1' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'project', projectKey: 'repo:repo-1' } + }, 'ipc' ) // An exact match, so a leaked `within` would fail here as an extra key. @@ -56,7 +64,11 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', within: { kind: 'project', projectKey: 'repo:elsewhere' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'project', projectKey: 'repo:elsewhere' } + }, 'ipc' ) // The service owns the answer, because it owns the consent and readiness @@ -74,7 +86,11 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) await searchSessionService( - { query: 'needle', within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } + }, 'ipc' ) expect(service.search).toHaveBeenCalledWith( @@ -98,7 +114,11 @@ describe('scope identity at the search choke point', () => { settings: { workspaceDir: '/home/me/ws', nestWorkspaces: false } })) await searchSessionService( - { query: 'needle', within: { kind: 'project', projectKey: 'repo:repo-1' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'project', projectKey: 'repo:repo-1' } + }, 'ipc' ) const call = service.search.mock.lastCall @@ -113,7 +133,7 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await searchSessionService({ query: 'needle' }, 'ipc') + await searchSessionService({ query: 'needle', supportsQoderHistory: true }, 'ipc') expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }, undefined) }) @@ -121,7 +141,10 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await searchSessionService({ query: 'needle', filters: { scopePaths: ['/other'] } }, 'ipc') + await searchSessionService( + { query: 'needle', supportsQoderHistory: true, filters: { scopePaths: ['/other'] } }, + 'ipc' + ) expect(service.search).toHaveBeenCalledWith( { query: 'needle', limit: 20, filters: { scopePaths: ['/other'] } }, undefined 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 b0f61ba6a99..36ca58e7d80 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,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' -import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import { + unavailableSessionSearchStatus, + createSessionSearchClient +} from '../../shared/ai-vault-search-client' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { setSessionSearchService, searchSessionService, @@ -51,12 +55,49 @@ describe('session search service registry', () => { 'ipc' ) expect(service.reconcile).not.toHaveBeenCalled() - expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }, undefined) + expect(service.search).toHaveBeenCalledWith( + { + query: 'needle', + limit: 20, + filters: { agents: AI_VAULT_AGENTS.filter((a) => a !== 'qoder') } + }, + undefined + ) expect(result).not.toHaveProperty('debug') expect(await searchSessionService({ query: 'needle', debug: true }, 'ipc')).toHaveProperty( 'debug' ) }) + it.each(['ipc', 'runtime', 'relay'] as const)( + 'negotiates Qoder hits before retrieval on %s, without widening explicit filters', + async (transport) => { + const service = fakeSearchService() + setSessionSearchService(service) + await searchSessionService( + { query: 'proof', filters: { agents: ['claude', 'qoder'] } }, + transport + ) + expect(service.search).toHaveBeenLastCalledWith( + { query: 'proof', limit: 20, filters: { agents: ['claude'] } }, + undefined + ) + expect( + await searchSessionService({ query: 'proof', filters: { agents: ['qoder'] } }, transport) + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + const client = createSessionSearchClient( + (method, request) => + method === 'aiVault.searchStatus' + ? sessionSearchServiceStatus(request, transport) + : searchSessionService(request, transport), + transport + ) + await client.searchSessions({ query: 'proof', filters: { agents: ['qoder'] } }) + expect(service.search).toHaveBeenLastCalledWith( + { query: 'proof', limit: 20, filters: { agents: ['qoder'] } }, + undefined + ) + } + ) 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 deffbcfa18f..2740c34cac6 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -14,6 +14,7 @@ import { type SessionSearchTransport } from '../../shared/ai-vault-search-transport' import type { SessionSearchService } from './session-search-service' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' let service: SessionSearchService | null = null @@ -34,7 +35,23 @@ 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, ...request } = parsed + const { within, supportsQoderHistory, ...request } = parsed + // Older clients reject the whole page when a hit has an unknown agent tag. + const requestedAgents = request.filters?.agents + const compatibleAgents = (requestedAgents?.length ? requestedAgents : AI_VAULT_AGENTS).filter( + (agent) => supportsQoderHistory || agent !== 'qoder' + ) + const compatibleRequest = supportsQoderHistory + ? request + : { + ...request, + filters: { + ...request.filters, + agents: compatibleAgents.length + ? compatibleAgents + : AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') + } + } const hostScope = within ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) : undefined @@ -42,10 +59,15 @@ export async function searchSessionService( request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false - const result = AiVaultSearchResponseSchema.parse(await current.search(request, hostScope)) + const result = AiVaultSearchResponseSchema.parse( + await current.search(compatibleRequest, hostScope) + ) if (result.kind !== 'results') { return result } + if (compatibleAgents.length === 0) { + return { ...result, hits: [], page: { cursor: null, hasMore: false } } + } const { debug, ...fields } = result return { ...fields, @@ -61,9 +83,10 @@ export async function sessionSearchServiceStatus( ): Promise { AiVaultSearchStatusRequestSchema.parse(raw) return redactStatusForTransport( - AiVaultSearchStatusSchema.parse( - service ? await service.status() : unavailableSessionSearchStatus() - ), + AiVaultSearchStatusSchema.parse({ + ...(service ? await service.status() : unavailableSessionSearchStatus()), + supportsQoderHistory: true + }), transport ) } diff --git a/src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl b/src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl new file mode 100644 index 00000000000..23b8dca2b6c --- /dev/null +++ b/src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl @@ -0,0 +1,29 @@ +{"type": "workspace-directories", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "directories": ["/tmp/qoder-proof"]} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925189036} +{"type": "worktree-state", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "worktreeSession": null} +{"type": "user", "uuid": "5231d503-07ef-4da9-86a6-8b24ce29b5d9", "timestamp": "2026-10-02T07:13:09.827Z", "message": {"role": "user", "content": "Create proof.txt containing exactly QODER_ORCA_PROOF_1002. Then respond with QODER_ORCA_COMPLETE_1002."}, "permissionMode": "acceptEdits", "origin": {"kind": "human"}, "promptId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "humanInput": {"text": "Create proof.txt containing exactly QODER_ORCA_PROOF_1002. Then respond with QODER_ORCA_COMPLETE_1002.", "mode": "prompt"}, "requestSetId": "ed2acf2f-b319-48c6-be63-34e7489c0dd8", "parentUuid": null, "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "0d6e72ea-d565-472d-81b4-9fe05f0d97c8", "explicit": false, "timestamp": 1790925189964} +{"type": "assistant", "uuid": "31d468b2-4206-4e9f-8866-5ca51de917e9", "timestamp": "2026-10-02T07:13:12.821Z", "message": {"id": "chatcmpl-efc1370b-f5de-9bb4-87df-6376580124bd", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "thinking", "thinking": "I'm creating the file now.\n", "signature": ""}]}, "parentUuid": "0d6e72ea-d565-472d-81b4-9fe05f0d97c8", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "45a9c19e-5c8c-4e9d-9a9b-5629fa545ec3", "timestamp": "2026-10-02T07:13:12.821Z", "message": {"id": "chatcmpl-efc1370b-f5de-9bb4-87df-6376580124bd", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "text", "text": "Creating the file.\n\n", "citations": null}]}, "parentUuid": "31d468b2-4206-4e9f-8866-5ca51de917e9", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "timestamp": "2026-10-02T07:13:12.821Z", "message": {"id": "chatcmpl-efc1370b-f5de-9bb4-87df-6376580124bd", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": "tool_use", "stop_sequence": null, "content": [{"type": "tool_use", "id": "call_f70efeb224b74814a8be55f8", "name": "Write", "input": {"content": "QODER_ORCA_PROOF_1002", "file_path": "/tmp/qoder-proof/proof.txt"}}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}, "parentUuid": "45a9c19e-5c8c-4e9d-9a9b-5629fa545ec3", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "explicit": false, "timestamp": 1790925192821} +{"type": "user", "uuid": "ae5db723-c9f4-4a35-902d-d4b8de01e431", "timestamp": "2026-10-02T07:13:13.317Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_f70efeb224b74814a8be55f8", "content": "File created successfully at: /tmp/qoder-proof/proof.txt"}]}, "sourceToolAssistantUUID": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "promptId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "toolUseResult": {"success": true, "file_path": "/tmp/qoder-proof/proof.txt", "bytesWritten": 21, "additions": 0, "deletions": 0}, "parentUuid": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "ae5db723-c9f4-4a35-902d-d4b8de01e431", "explicit": false, "timestamp": 1790925193317} +{"type": "assistant", "uuid": "6fcecbbd-5bf1-4186-8766-369beb156bc9", "timestamp": "2026-10-02T07:13:15.794Z", "message": {"id": "chatcmpl-aee1d927-eb24-9aad-8b58-9d66ab7a1fb4", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "thinking", "thinking": "\"Containing exactly QODER_ORCA_PROOF_1002\" — I wrote without trailing newline. That's exact.\n", "signature": ""}]}, "parentUuid": "ae5db723-c9f4-4a35-902d-d4b8de01e431", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "timestamp": "2026-10-02T07:13:15.794Z", "message": {"id": "chatcmpl-aee1d927-eb24-9aad-8b58-9d66ab7a1fb4", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": "end_turn", "stop_sequence": null, "content": [{"type": "text", "text": "QODER_ORCA_COMPLETE_1002", "citations": null}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}, "parentUuid": "6fcecbbd-5bf1-4186-8766-369beb156bc9", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "explicit": false, "timestamp": 1790925195794} +{"type": "last-prompt", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "lastPrompt": "Create proof.txt containing exactly QODER_ORCA_PROOF_1002. Then respond with QODER_ORCA_COMPLETE_1002."} +{"type": "workspace-directories", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "directories": ["/tmp/qoder-proof"]} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925189036} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "explicit": false, "timestamp": 1790925195794} +{"type": "worktree-state", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "worktreeSession": null} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925244267} +{"type": "user", "uuid": "029ca680-b833-4301-9ce2-0d3d372d93dc", "timestamp": "2026-10-02T07:14:04.785Z", "message": {"role": "user", "content": "What exact marker did you write to proof.txt? Reply only with that marker."}, "permissionMode": "default", "origin": {"kind": "human"}, "promptId": "fd63d141-bc1c-4fe5-b46e-117d6e84579c", "humanInput": {"text": "What exact marker did you write to proof.txt? Reply only with that marker.", "mode": "prompt"}, "requestSetId": "aa08407f-0504-47a9-8e97-53595595be15", "parentUuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "029ca680-b833-4301-9ce2-0d3d372d93dc", "explicit": false, "timestamp": 1790925244785} +{"type": "assistant", "uuid": "8212543a-36a1-489c-a5f6-65ee294d2faf", "timestamp": "2026-10-02T07:14:07.626Z", "message": {"id": "chatcmpl-dc06221e-db1d-97aa-9a40-69d27c884243", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "thinking", "thinking": "The user asks what exact marker I wrote. Let me just answer. I could verify by reading the file, but the instruction says don't re-read a file just to verify. The user asks a direct question; answer with the marker only.\n\nBut accuracy matters — \"exact marker\". I'm confident it's QODER_ORCA_PROOF_1002.", "signature": ""}]}, "parentUuid": "029ca680-b833-4301-9ce2-0d3d372d93dc", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "3f2475e3-00e5-4e36-9b3c-0d02618d177a", "timestamp": "2026-10-02T07:14:07.626Z", "message": {"id": "chatcmpl-dc06221e-db1d-97aa-9a40-69d27c884243", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": "end_turn", "stop_sequence": null, "content": [{"type": "text", "text": "QODER_ORCA_PROOF_1002", "citations": null}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}, "parentUuid": "8212543a-36a1-489c-a5f6-65ee294d2faf", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "3f2475e3-00e5-4e36-9b3c-0d02618d177a", "explicit": false, "timestamp": 1790925247626} +{"type": "last-prompt", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "lastPrompt": "What exact marker did you write to proof.txt? Reply only with that marker."} +{"type": "workspace-directories", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "directories": ["/tmp/qoder-proof"]} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925244267} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "3f2475e3-00e5-4e36-9b3c-0d02618d177a", "explicit": false, "timestamp": 1790925247626} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index dfd012415d5..16b0938fee9 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,3 +1,4 @@ +import { parseQoderSessionContent } from './session-scanner-qoder-parser' import { remoteSessionDocumentParsers } from './remote-session-document-parsers' import type { RemoteSessionContent } from './remote-session-content-lines' import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' @@ -77,6 +78,16 @@ export function remoteSessionSources( ), partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, + { + ...jsonlSource( + 'qoder', + remoteHome, + hostPlatform, + ['.qoder', 'projects'], + parseQoderSessionContent + ), + partitionSubagentTranscripts: partitionSubagentTranscriptPaths + }, remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 95910d208b7..be2783e215e 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,3 +1,4 @@ +import { parseQoderSessionFile } from './session-scanner-qoder-parser' import { parseCodebuddySessionFile } from './session-scanner-codebuddy-parser' import type { AiVaultSession } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' @@ -88,6 +89,8 @@ export async function parseAgentSessionFile( signal?: AbortSignal ): Promise { switch (candidate.agent) { + case 'qoder': + return parseQoderSessionFile(candidate.file, platform, messages) case 'codebuddy': return parseCodebuddySessionFile(candidate.file, platform, messages) case 'claude': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 66d069b68cc..b9b5756b8fd 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -115,6 +115,16 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // their parent instead. directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME }, + qoder: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs( + options.qoderProjectsDir ?? join(homedir(), '.qoder', 'projects'), + wslHomeDirs, + ['.qoder', 'projects'] + ), + extensions: ['.jsonl'], + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, codebuddy: { rootDirs: (options, wslHomeDirs) => [ options.codebuddyProjectsDir ?? CODEBUDDY_PROJECTS_DIR, diff --git a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts index be080271269..c460ca7cc19 100644 --- a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts +++ b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts @@ -32,6 +32,20 @@ export async function writeLogAgentFixtures( ]) ) + await mkdir(join(roots.qoderProjectsDir, 'project'), { recursive: true }) + await writeFile( + join(roots.qoderProjectsDir, 'project', 'qoder-session.jsonl'), + jsonlBody([ + { type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/qoder'] }, + { + type: 'user', + sessionId: 'qoder-session', + timestamp: '2026-05-01T10:00:00.000Z', + message: { role: 'user', content: 'Qoder title' } + } + ]) + ) + // CodeBuddy writes its own message-record shape under its own root. await mkdir(join(roots.codebuddyProjectsDir, 'project'), { recursive: true }) await writeFile( diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index a00f4edd2cf..3fefd825833 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,3 +1,4 @@ +import { createQoderSessionResumeState } from './session-scanner-qoder-parser' import type { AiVaultSession } from '../../shared/ai-vault-types' import { inSessionParseFileLane } from './session-parse-file-lane' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' @@ -52,6 +53,8 @@ function resumableStateFactoryFor( switch (candidate.agent) { case 'claude': return (messages) => createClaudeSessionResumeState(candidate.file, messages) + case 'qoder': + return (messages) => createQoderSessionResumeState(candidate.file, messages) case 'codebuddy': return (messages) => createCodebuddySessionResumeState(candidate.file, messages) case 'codex': diff --git a/src/main/ai-vault/session-scanner-qoder-parser.ts b/src/main/ai-vault/session-scanner-qoder-parser.ts new file mode 100644 index 00000000000..47c56e973eb --- /dev/null +++ b/src/main/ai-vault/session-scanner-qoder-parser.ts @@ -0,0 +1,116 @@ +import { createInterface } from 'node:readline' +import type { ExecutionHostId } from '../../shared/execution-host' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' +import { accumulatorSessionIdentity, updateTimeline } from './session-scanner-accumulator' +import { + cloneClaudeSessionParseState, + consumeClaudeSessionLine, + createClaudeSessionParseState, + finalizeClaudeSessionParseState, + type ClaudeSessionParseState +} from './session-scanner-primary-parsers' +import { asRecord, extractString, parseJsonObject } from './session-scanner-values' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' +import type { FileWithMtime, ResumableSessionParseState } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' + +type ParserOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +function createQoderState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ClaudeSessionParseState { + const state = createClaudeSessionParseState(file, messages) + state.accumulator.agent = 'qoder' + return state +} + +function consumeQoderLine(state: ClaudeSessionParseState, line: string): void { + // Qoder 1.1.64 shares Claude's turns, titles and tool blocks, with extra startup records. + const record = parseJsonObject(line) + if (!record) { + return + } + const message = asRecord(record.message) + if (message && Array.isArray(message.content)) { + const content = message.content.filter((block) => asRecord(block)?.type === 'text') + consumeClaudeSessionLine(state, JSON.stringify({ ...record, message: { ...message, content } })) + } else { + consumeClaudeSessionLine(state, line) + } + if ( + record.type === 'workspace-directories' && + !state.accumulator.cwd && + Array.isArray(record.directories) + ) { + state.accumulator.cwd = extractString(record.directories[0]) + } + if (record.type === 'runtime-config') { + state.accumulator.model = extractString(record.model) ?? state.accumulator.model + updateTimeline(state.accumulator, record.timestamp) + } +} + +function resumeState(state: ClaudeSessionParseState): ResumableSessionParseState { + return { + consumeLine: (line) => consumeQoderLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => resumeState(cloneClaudeSessionParseState(state)), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => finalizeClaudeSessionParseState(state, platform, options) + } +} + +export function createQoderSessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { + return resumeState(createQoderState(file, messages)) +} + +export async function parseQoderSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink +) { + const stream = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input: stream, crlfDelay: Infinity }) + try { + return await parseLines(file, lines, platform, {}, messages) + } finally { + lines.close() + stream.destroy() + } +} + +export async function parseQoderSessionContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform = process.platform, + options: ParserOptions = {}, + signal?: AbortSignal +) { + return parseLines(file, remoteSessionContentLines(content, signal), platform, options) +} + +async function parseLines( + file: FileWithMtime, + lines: AsyncIterable | Iterable, + platform: NodeJS.Platform, + options: ParserOptions, + messages?: TranscriptMessageSink +) { + const state = createQoderState(file, messages) + for await (const line of lines) { + consumeQoderLine(state, line) + } + return finalizeClaudeSessionParseState(state, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-qoder.test.ts b/src/main/ai-vault/session-scanner-qoder.test.ts new file mode 100644 index 00000000000..78c99ddab88 --- /dev/null +++ b/src/main/ai-vault/session-scanner-qoder.test.ts @@ -0,0 +1,105 @@ +import { readFile, mkdtemp, rm, stat } from 'node:fs/promises' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' +import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' +import { scanAiVaultSessions } from './session-scanner' +import { + createQoderSessionResumeState, + parseQoderSessionContent +} from './session-scanner-qoder-parser' +import { remoteSessionSources } from './remote-session-scanner-sources' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import type { TranscriptMessage } from './session-transcript-consumers' + +const roots: string[] = [] +afterEach(async () => { + resetSessionParseCacheForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +it('decodes a real generated and resumed Qoder 1.1.64 session for preview and search', async () => { + const path = join(__dirname, '__fixtures__', 'qoder-1.1.64-generated-resumed.jsonl') + const content = await readFile(path, 'utf8') + const file = { path, mtimeMs: 1, modifiedAt: '2026-10-02T07:14:07.626Z' } + const messages: TranscriptMessage[] = [] + const state = createQoderSessionResumeState(file, { + active: true, + push: (message) => messages.push(message) + }) + const lines = content.trim().split('\n') + const split = lines.findIndex((line) => line.includes('What exact marker')) + for (const line of lines.slice(0, split)) { + state.consumeLine(line) + } + const before = await state.finalize('darwin') + const resumed = state.clone() + for (const line of lines.slice(split)) { + resumed.consumeLine(line) + } + const after = await resumed.finalize('darwin') + expect(after).toMatchObject({ + agent: 'qoder', + cwd: '/tmp/qoder-proof', + model: 'qmodel_38max', + sessionId: 'faa75b79-790e-4c44-8f3e-c7145d18eb7e' + }) + expect(after?.resumeCommand).toContain("qodercli --resume 'faa75b79-790e-4c44-8f3e-c7145d18eb7e'") + expect(after?.previewMessages.at(-1)?.text).toBe('QODER_ORCA_PROOF_1002') + expect(before?.previewMessages.at(-1)?.text).toBe('QODER_ORCA_COMPLETE_1002') + expect(messages.some((m) => m.role === 'assistant' && m.text === 'QODER_ORCA_PROOF_1002')).toBe( + true + ) + expect(messages.some((m) => m.text.includes("I'm creating the file now"))).toBe(false) + expect(messages.every((message) => message.role !== 'tool')).toBe(true) + expect(messages.some((message) => message.text.includes('File created successfully'))).toBe(false) + expect( + after?.previewMessages.some((message) => message.text.includes('File created successfully')) + ).toBe(false) + const remote = await parseQoderSessionContent(file, content, 'linux', { + executionHostId: 'ssh:test' + }) + expect(remote).toMatchObject({ + agent: 'qoder', + executionHostId: 'ssh:test', + cwd: '/tmp/qoder-proof' + }) +}) + +it('discovers Qoder folder history and prunes nested workers on local, WSL, and SSH hosts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-qoder-vault-')) + roots.push(root) + const options = isolatedScanRoots(root) + const path = join(options.qoderProjectsDir, 'folder', 'qoder-session.jsonl') + const records = [ + { type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/folder'] }, + { + type: 'user', + sessionId: 'qoder-session', + message: { role: 'user', content: 'Qoder folder proof' } + } + ] + await writeJsonlFile(path, records) + await writeJsonlFile( + join(options.qoderProjectsDir, 'folder', 'qoder-session', 'subagents', 'worker.jsonl'), + records + ) + const result = await scanAiVaultSessions({ ...options, wslHomeDirs: [] }) + const sessions = result.sessions.filter((s) => s.agent === 'qoder') + expect(sessions).toHaveLength(1) + expect(sessions[0]).toMatchObject({ title: 'Qoder folder proof', cwd: '/tmp/folder' }) + expect(AI_VAULT_AGENT_SOURCES.qoder?.rootDirs(options, ['/home/test'])).toContain( + join('/home/test', '.qoder', 'projects') + ) + for (const platform of ['darwin-arm64', 'linux-x64', 'win32-x64'] as const) { + const source = remoteSessionSources( + platform === 'win32-x64' ? 'C:\\Users\\test' : '/home/test', + getRemoteHostPlatform(platform) + ).find((s) => s.agent === 'qoder') + expect(source).toBeDefined() + expect(source?.partitionSubagentTranscripts).toBeDefined() + } + expect((await stat(path)).size).toBeGreaterThan(0) +}) diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0d9b8cef5ab..c21a7b9a7b3 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -55,6 +55,7 @@ export function isolatedScanRoots(root: string) { return { claudeProjectsDir: join(root, 'claude-projects'), codebuddyProjectsDir: join(root, 'codebuddy-projects'), + qoderProjectsDir: join(root, 'qoder-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), antigravityBrainDir: join(root, 'antigravity-brain'), diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index be97a4b3187..2a285409ac2 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -15,6 +15,7 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' export type AiVaultScanOptions = { claudeProjectsDir?: string codebuddyProjectsDir?: string + qoderProjectsDir?: string codexSessionsDir?: string additionalCodexSessionsDirs?: readonly string[] // Why: tests inject a sandbox "real ~/.codex" so real-home attribution diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index be4028460a0..bdcf8054f24 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -85,7 +85,8 @@ describe('desktop IPC and preload search boundary', () => { const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host') expect(sshSearch).toHaveBeenCalledWith('ssh-host', 'aiVault.searchSessions', { query: 'needle', - limit: 20 + limit: 20, + supportsQoderHistory: true }) expect(result).toMatchObject({ hits: [{ executionHostId: 'ssh:ssh-host', source: { presence: 'present' } }] @@ -105,7 +106,8 @@ describe('desktop IPC and preload search boundary', () => { const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1') expect(runtimeSearch).toHaveBeenCalledWith('env-1', 'aiVault.searchSessions', { query: 'needle', - limit: 20 + limit: 20, + supportsQoderHistory: true }) expect(result).toMatchObject({ hits: [{ executionHostId: 'runtime:env-1', source: { presence: 'present' } }] diff --git a/src/main/qoder/real-cli.test.ts b/src/main/qoder/real-cli.test.ts new file mode 100644 index 00000000000..7cd1e8a139d --- /dev/null +++ b/src/main/qoder/real-cli.test.ts @@ -0,0 +1,187 @@ +import { + cp, + mkdtemp, + mkdir, + readFile, + readdir, + realpath, + rm, + stat, + writeFile +} from 'node:fs/promises' +import { createServer } from 'node:http' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeAndAccept } from '../../shared/agent-hook-listener-test-harness' +import { parseQoderSessionFile } from '../ai-vault/session-scanner-qoder-parser' + +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal() + return { ...original, homedir: () => sandbox.home || original.homedir() } +}) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { qoderHookService } from './hook-service' +import { markQoderWorkspaceTrusted } from './workspace-trust' + +it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( + 'generates and resumes a real task through managed hooks', + async () => { + const realHome = homedir() + sandbox.home = await realpath(await mkdtemp(join(tmpdir(), 'orca-qoder-real-'))) + const config = join(sandbox.home, '.qoder') + const workspace = join(sandbox.home, 'folder') + const statuses: { + event: unknown + state: unknown + agent: unknown + session: string | undefined + }[] = [] + const listener = createHookListenerState() + const server = createServer(async (request, response) => { + let content = '' + for await (const chunk of request) { + content += chunk + } + const fields = new URLSearchParams(content) + const payload = JSON.parse(fields.get('payload') ?? '{}') + const normalized = normalizeAndAccept(listener, 'qoder', payload) + statuses.push({ + event: payload.hook_event_name, + state: normalized?.payload.state, + agent: normalized?.payload.agentType, + session: normalized?.providerSession?.id + }) + response.writeHead(request.url === '/hook/qoder' ? 200 : 404).end() + }) + try { + await mkdir(workspace) + await mkdir(config) + await cp(join(realHome, '.qoder', '.auth'), join(config, '.auth'), { recursive: true }) + await writeFile( + join(config, 'settings.json'), + JSON.stringify({ general: { enableAutoUpdate: false } }) + ) + expect(qoderHookService.install().state).toBe('installed') + markQoderWorkspaceTrusted(workspace, sandbox.home) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('No test receiver port') + } + const env = { + ...process.env, + HOME: sandbox.home, + USERPROFILE: sandbox.home, + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'qoder-proof-pane', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_TRANSPORT: '', + ORCA_BACKGROUND_LAUNCH: '1' + } + const command = process.env.ORCA_QODER_CLI_PATH ?? join(realHome, '.local', 'bin', 'qodercli') + const args = [ + '--config-dir', + config, + '--cwd', + workspace, + '--max-model-request-retries', + '0', + '--print' + ] + const generated = await runProcess({ + program: command, + args: [ + ...args, + '--permission-mode', + 'accept_edits', + 'Create proof.txt containing exactly QODER_MANAGED_PROOF. Then reply QODER_MANAGED_COMPLETE.' + ], + env, + timeoutMs: 90000 + }) + expect(generated.code).toBe(0) + expect((await readFile(join(workspace, 'proof.txt'), 'utf8')).trim()).toBe( + 'QODER_MANAGED_PROOF' + ) + const session = statuses.find((s) => s.event === 'SessionStart')?.session + expect(session).toBeTruthy() + if (!session) { + throw new Error('Qoder did not identify its session') + } + const resumed = await runProcess({ + program: command, + args: [ + ...args, + '--resume', + session, + 'What exact marker did you write? Reply only with that marker.' + ], + env, + timeoutMs: 90000 + }) + expect(resumed.code).toBe(0) + expect(resumed.stdout.trim()).toBe('QODER_MANAGED_PROOF') + const projects = join(config, 'projects') + const transcript = (await readdir(projects, { recursive: true })).find( + (path) => path.endsWith(`${session}.jsonl`) && !path.includes('subagents') + ) + if (!transcript) { + throw new Error('Qoder did not persist the generated and resumed session') + } + const path = join(projects, transcript) + const modified = await stat(path) + const history = await parseQoderSessionFile({ + path, + mtimeMs: modified.mtimeMs, + modifiedAt: modified.mtime.toISOString() + }) + expect(history).toMatchObject({ agent: 'qoder', sessionId: session, cwd: workspace }) + expect(history?.previewMessages.at(-1)?.text).toBe('QODER_MANAGED_PROOF') + expect(history?.resumeCommand).toContain(`qodercli --resume '${session}'`) + expect(statuses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'SessionStart', + state: 'done', + agent: 'qoder', + session + }), + expect.objectContaining({ event: 'UserPromptSubmit', state: 'working', agent: 'qoder' }), + expect.objectContaining({ event: 'Stop', state: 'done', agent: 'qoder' }) + ]) + ) + const evidencePath = process.env.ORCA_QODER_PROOF_PATH + if (evidencePath) { + await writeFile( + evidencePath, + JSON.stringify( + { + generated: generated.stdout.trim(), + resumed: resumed.stdout.trim(), + statuses, + history: { + agent: history?.agent, + sessionId: history?.sessionId, + lastReply: history?.previewMessages.at(-1)?.text, + resumeVerified: true + } + }, + null, + 2 + ) + ) + } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + await rm(sandbox.home, { recursive: true, force: true }) + sandbox.home = '' + } + }, + 180000 +) 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 ae4a6bf7a5f..6f3b4301f70 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -1,4 +1,5 @@ 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' @@ -52,7 +53,11 @@ describe('session search runtime RPC', () => { expect(text.includes('/host/transcript.jsonl')).toBe(clientKind === undefined) expect(text.includes('resumeCommand')).toBe(clientKind === undefined) expect(service.search).toHaveBeenCalledExactlyOnceWith( - { query: 'needle', limit: 20 }, + { + query: 'needle', + limit: 20, + filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + }, undefined ) const status = await rpc.dispatch( @@ -62,6 +67,27 @@ describe('session search runtime RPC', () => { expect(status).toMatchObject({ ok: true, result: { enabled: true, generation: 7 } }) } ) + it.each([undefined, 'runtime', 'mobile'] as const)( + 'preserves explicitly supported Qoder filters for client kind %s', + async (clientKind) => { + const service = fakeSearchService() + setSessionSearchService(service) + expect( + await dispatcher().dispatch( + request({ + query: 'needle', + supportsQoderHistory: true, + filters: { agents: ['qoder', 'codex'] } + }), + { clientKind } + ) + ).toMatchObject({ ok: true }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'needle', limit: 20, filters: { agents: ['qoder', 'codex'] } }, + 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/relay/session-search-transport.test.ts b/src/relay/session-search-transport.test.ts index f0765af8f93..40752398159 100644 --- a/src/relay/session-search-transport.test.ts +++ b/src/relay/session-search-transport.test.ts @@ -3,6 +3,7 @@ import { RelayDispatcher } from './dispatcher' import { AiVaultHandler } from './ai-vault-handler' import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' import { createSessionSearchClient } from '../shared/ai-vault-search-client' +import { AI_VAULT_AGENTS } from '../shared/ai-vault-types' import { fakeSearchService } from '../shared/ai-vault-search-test-fixture' import { setSessionSearchService } from '../main/ai-vault-search/session-search-service-registry' @@ -52,7 +53,14 @@ describe('session search over real relay frames', () => { expect(JSON.stringify(raw)).not.toContain('/host/transcript') expect(JSON.stringify(raw)).not.toContain('/host/codex') expect(JSON.stringify(raw)).not.toContain('resumeCommand') - expect(service.search).toHaveBeenLastCalledWith({ query: 'needle', limit: 20 }, undefined) + expect(service.search).toHaveBeenLastCalledWith( + { + query: 'needle', + limit: 20, + filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + }, + undefined + ) expect(service.reconcile).not.toHaveBeenCalled() expect(await client.searchStatus()).toMatchObject({ enabled: true, generation: 7 }) await expect(mux.request('aiVault.searchSessions', { query: 42 })).rejects.toThrow() diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx index afce262f459..75c7b6f3de6 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx @@ -49,6 +49,12 @@ function renderPanel(search: PanelSearch) { } describe('AiVaultPanelSearch', () => { + it('explains an unsupported agent without reporting zero matches or a missing host service', () => { + renderPanel(panelSearch({ response: { kind: 'unavailable', reason: 'unsupported-agent' } })) + expect(screen.getByRole('status').textContent).toContain( + 'does not support history search for the selected agent' + ) + }) it('names every computer the merge could not search, with its reason', () => { const response = searchResults() renderPanel( diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx index 833921a9ce4..ecee06a14cd 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx @@ -24,6 +24,8 @@ function hostSkipReason(outcome: AiVaultSearchHostOutcome['outcome']): string | return 'unreachable' case 'scope-unknown': return 'scope not found there' + case 'unsupported-agent': + return 'agent not supported' } } @@ -111,6 +113,11 @@ export function AiVaultPanelSearch({ 'sessionSearch.panel.noService', 'Search is unavailable on this computer. It may need an Orca update or a runtime with search support.' ) + } else if (unavailable === 'unsupported-agent') { + message = translate( + 'sessionSearch.panel.unsupportedAgent', + 'This computer does not support history search for the selected agent. Update Orca on that computer or select another agent.' + ) } else if (unavailable === 'scope-unknown') { message = translate( 'sessionSearch.panel.scopeUnknown', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1e448298aa5..b751ce57730 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18579,6 +18579,7 @@ "remoteDisabled": "Search is disabled on this computer. Enable transcript indexing on that computer to search its sessions.", "notReady": "The search index is not ready yet. Try again shortly.", "noService": "Search is unavailable on this computer. It may need an Orca update or a runtime with search support.", + "unsupportedAgent": "This computer does not support history search for the selected agent. Update Orca on that computer or select another agent.", "failed": "Could not search this computer. Check its connection and try again.", "changed": "The index changed while searching. Search again for current results.", "truncated": "Some results or matching text were limited. Narrow your search for more precise results.", diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts index 215ffc928f5..1300be5499a 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -20,7 +20,8 @@ describe('web session search preload compatibility', () => { const result = await api.searchSessions({ query: 'needle' }) expect(callRuntimeResult).toHaveBeenCalledExactlyOnceWith('aiVault.searchSessions', { query: 'needle', - limit: 20 + limit: 20, + supportsQoderHistory: true }) expect(result).toMatchObject({ kind: 'results', hits: [{ source: { presence: 'present' } }] }) expect(JSON.stringify(result)).not.toContain('resumeCommand') diff --git a/src/shared/__fixtures__/pre-qoder-search-request.ts b/src/shared/__fixtures__/pre-qoder-search-request.ts new file mode 100644 index 00000000000..bfe585f0326 --- /dev/null +++ b/src/shared/__fixtures__/pre-qoder-search-request.ts @@ -0,0 +1,58 @@ +// Pre-Qoder b49abdb request parser and closed agent enum; keep independent of the current catalog. +import { resolveSessionSearchLimit } from '../ai-vault-search-limit' +import { z } from 'zod' +export const AI_VAULT_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 +const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64 +const AI_VAULT_SEARCH_SORTS = ['relevance', 'newest'] as const +import { AiVaultSearchScopeIdentitySchema } from '../ai-vault-search-scope' + +export const AiVaultSearchFiltersSchema = z.object({ + agents: z.array(z.enum(AI_VAULT_AGENTS)).optional(), + scopePaths: z.array(z.string().min(1).max(4096)).max(AI_VAULT_SCOPE_PATHS_MAX_COUNT).optional(), + since: z.string().datetime({ offset: true }).optional(), + sort: z.enum(AI_VAULT_SEARCH_SORTS).optional() +}) + +// Strip unknown fields so legacy tier/refresh are accepted without affecting the query. +export const AiVaultSearchRequestSchema = z + .object({ + query: z.string(), + scope: z.enum(['conversation', 'all']).optional(), + freshness: z.enum(['indexed', 'wait-until-current']).optional(), + limit: z.number().optional().transform(resolveSessionSearchLimit), + cursor: z.string().optional(), + filters: AiVaultSearchFiltersSchema.optional(), + /** Scope by identity, resolved into paths by whichever host answers. */ + within: AiVaultSearchScopeIdentitySchema.optional(), + debug: z.boolean().optional() + }) + // Two scopes in one request have no defined intersection, and guessing one + // would be the silent widening this field exists to remove. Neither is still + // legal and still means every session. + .refine( + (request) => request.within === undefined || (request.filters?.scopePaths ?? []).length === 0, + { message: 'A search carries either a scope identity or explicit scope paths, not both' } + ) diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index efb2ddbe5d3..60f50023188 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -218,6 +218,7 @@ function buildAgentResumeInvocation( return `${baseCommand} resume ${sessionArg}` case 'cline': return `${baseCommand} --id ${sessionArg}` + case 'qoder': case 'codebuddy': case 'claude': case 'zcode': diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index dd68bbd6c67..66501b2994b 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -46,9 +46,21 @@ export function createSessionSearchClient( return { searchSessions: async (request) => { const parsed = AiVaultSearchRequestSchema.parse(request) + let hostRequest = parsed let raw: unknown try { - raw = await call('aiVault.searchSessions', parsed) + // IPC and its all-hosts merge are this build; each remote leg negotiates its own host. + if (transport !== 'ipc' && parsed.filters?.agents?.includes('qoder')) { + 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 } } + } + } + raw = await call('aiVault.searchSessions', { ...hostRequest, supportsQoderHistory: true }) } 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 15733400234..8966b1bfe77 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -23,6 +23,7 @@ export const AiVaultSearchRequestSchema = z limit: z.number().optional().transform(resolveSessionSearchLimit), cursor: z.string().optional(), filters: AiVaultSearchFiltersSchema.optional(), + supportsQoderHistory: z.boolean().optional(), /** Scope by identity, resolved into paths by whichever host answers. */ within: AiVaultSearchScopeIdentitySchema.optional(), debug: z.boolean().optional() @@ -90,7 +91,8 @@ export const AiVaultSearchHostOutcomeSchema = z.object({ 'no-service', 'unreachable', // This host does not know the workspace or project the scope named. - 'scope-unknown' + 'scope-unknown', + 'unsupported-agent' ]) }) const routeSchema = z.enum(['phrase', 'and', 'or', 'typo+phrase', 'typo+and', 'typo+or']) @@ -125,13 +127,14 @@ export const AiVaultSearchResponseSchema = z.discriminatedUnion('kind', [ kind: z.literal('unavailable'), // `scope-unknown` only ever answers a request that carried `within`, so a // client too old to send one can never receive a reason it cannot parse. - reason: z.enum(['disabled', 'not-ready', 'no-service', 'scope-unknown']) + reason: z.enum(['disabled', 'not-ready', 'no-service', 'scope-unknown', 'unsupported-agent']) }) ]) export const AiVaultSearchStatusRequestSchema = z.object({}) /** Consent flip for one host's index. Answered with that host's status after the change is applied. */ export const AiVaultSetSearchEnabledParamsSchema = z.object({ enabled: z.boolean() }) export const AiVaultSearchStatusSchema = z.object({ + supportsQoderHistory: z.boolean().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 new file mode 100644 index 00000000000..0df8dd802ea --- /dev/null +++ b/src/shared/ai-vault-search-qoder-client.test.ts @@ -0,0 +1,122 @@ +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 { searchHit, searchResults } from './ai-vault-search-test-fixture' + +describe('Qoder search negotiation', () => { + it('keeps the frozen old parser closed to Qoder', () => { + expect( + LegacyRequestSchema.safeParse({ query: 'q', filters: { agents: ['qoder'] } }).success + ).toBe(false) + }) + + it.each(['runtime', 'relay'] as const)( + 'sends no search request for sole unsupported Qoder over %s', + async (transport) => { + const search = vi.fn((params: unknown) => { + LegacyRequestSchema.parse(params) + return searchResults() + }) + const status = vi.fn(() => unavailableSessionSearchStatus()) + const client = createSessionSearchClient( + async (method, params) => (method === 'aiVault.searchStatus' ? status() : search(params)), + transport + ) + expect(await client.searchSessions({ query: 'q', filters: { agents: ['qoder'] } })).toEqual({ + kind: 'unavailable', + reason: 'unsupported-agent' + }) + expect(status).toHaveBeenCalledTimes(1) + expect(search).not.toHaveBeenCalled() + } + ) + + it.each(['runtime', 'relay'] as const)( + 'retains every non-Qoder filter through the actual old enum over %s', + async (transport) => { + const request = { + query: 'q', + scope: 'conversation' as const, + limit: 42, + cursor: 'page-1', + debug: true, + filters: { + agents: ['qoder', 'codex', 'claude'] as const, + scopePaths: ['/host/folder'], + since: '2026-08-01T00:00:00Z', + sort: 'newest' as const + } + } + const search = vi.fn((params: unknown) => { + expect(LegacyRequestSchema.parse(params)).toEqual({ + ...request, + filters: { ...request.filters, agents: ['codex', 'claude'] } + }) + return searchResults() + }) + const client = createSessionSearchClient( + async (method, params) => + method === 'aiVault.searchStatus' ? unavailableSessionSearchStatus() : search(params), + transport + ) + await client.searchSessions({ + ...request, + filters: { ...request.filters, agents: [...request.filters.agents] } + }) + expect(search).toHaveBeenCalledTimes(1) + } + ) + + it.each(['runtime', 'relay'] as const)( + 'preserves Qoder identity only after positive host attestation over %s', + async (transport) => { + const call = vi.fn(async (method: string) => + method === 'aiVault.searchStatus' + ? { ...unavailableSessionSearchStatus(), supportsQoderHistory: true } + : { ...searchResults(), hits: [{ ...searchHit(), agent: 'qoder' }] } + ) + const result = await createSessionSearchClient(call, transport).searchSessions({ + query: 'q', + filters: { agents: ['qoder'] } + }) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + expect(call).toHaveBeenLastCalledWith('aiVault.searchSessions', { + query: 'q', + limit: 20, + filters: { agents: ['qoder'] }, + supportsQoderHistory: true + }) + expect(result).toMatchObject({ kind: 'results', hits: [{ agent: 'qoder' }] }) + } + ) + + it('does not gate local IPC before the per-host aggregator negotiates', async () => { + const call = vi.fn(async () => searchResults()) + await createSessionSearchClient(call, 'ipc').searchSessions({ + query: 'q', + filters: { agents: ['qoder'] } + }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchSessions', { + query: 'q', + limit: 20, + filters: { agents: ['qoder'] }, + supportsQoderHistory: true + }) + }) + + it('does not dispatch a search when the capability probe loses host contact', async () => { + const call = vi.fn(async () => { + throw new Error('host disconnected') + }) + await expect( + createSessionSearchClient(call, 'relay').searchSessions({ + query: 'q', + filters: { agents: ['qoder', 'codex'] } + }) + ).rejects.toThrow('host disconnected') + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) +}) diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index e337f98d7a0..26431f05810 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', 'codebuddy', + 'qoder', 'codex', 'hermes', 'pi', @@ -53,6 +54,7 @@ export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', codebuddy: 'CodeBuddy', + qoder: 'Qoder', codex: 'Codex', hermes: 'Hermes', pi: 'Pi', 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 new file mode 100644 index 00000000000..0a00604408d --- /dev/null +++ b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../src/shared/ai-vault-search-client' +import { searchResults, fakeSearchService } from '../../../src/shared/ai-vault-search-test-fixture' +import { + searchSessionService, + setSessionSearchService +} from '../../../src/main/ai-vault-search/session-search-service-registry' +import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +afterEach(() => setSessionSearchService(null)) + +test('a pre-Qoder release can read current host search pages without losing other agents', async () => { + const checkout = await materializeReleaseCheckout('v1.4.211') + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/shared/ai-vault-search-contract.ts' + ) + const schema = baseline.AiVaultSearchResponseSchema + if ( + !schema || + typeof schema !== 'object' || + !('safeParse' in schema) || + typeof schema.safeParse !== 'function' + ) { + throw new Error('The pinned release has no search response parser') + } + const qoder = { ...searchResults().hits[0], agent: 'qoder' as const } + expect(schema.safeParse({ ...searchResults(), hits: [qoder] })).toHaveProperty('success', false) + const service = fakeSearchService() + service.search.mockImplementation(async (request) => ({ + ...searchResults(), + hits: + !request.filters?.agents || request.filters.agents.includes('qoder') + ? [qoder] + : searchResults().hits + })) + setSessionSearchService(service) + const oldResponse = await searchSessionService({ query: 'proof' }, 'relay') + expect(schema.safeParse(oldResponse)).toHaveProperty('success', true) + expect(oldResponse).toMatchObject({ hits: [{ agent: 'codex' }] }) + const client = createSessionSearchClient( + (_method, request) => searchSessionService(request, 'relay'), + 'relay' + ) + expect(await client.searchSessions({ query: 'proof' })).toMatchObject({ + hits: [{ agent: 'qoder' }] + }) + const oldRequest = baseline.AiVaultSearchRequestSchema + if ( + !oldRequest || + typeof oldRequest !== 'object' || + !('safeParse' in oldRequest) || + typeof oldRequest.safeParse !== 'function' + ) { + throw new Error('The pinned release has no search request parser') + } + expect(oldRequest.safeParse({ query: 'proof', supportsQoderHistory: true })).toHaveProperty( + 'success', + true + ) +}) + +test.each(['v1.4.211', 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'])( + 'a current client narrows Qoder filters before calling the actual %s request parser', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/shared/ai-vault-search-contract.ts' + ) + const requestSchema = baseline.AiVaultSearchRequestSchema + if ( + !requestSchema || + typeof requestSchema !== 'object' || + !('parse' in requestSchema) || + typeof requestSchema.parse !== 'function' + ) { + throw new Error('Pinned host has no search request parser') + } + const call = vi.fn(async (method: string, request: Record) => { + if (method === 'aiVault.searchStatus') { + return { ...unavailableSessionSearchStatus(), enabled: true, phase: 'current' } + } + requestSchema.parse(request) + return searchResults() + }) + const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' } + const client = createSessionSearchClient(call, 'relay') + expect( + await client.searchSessions({ + query: 'proof', + filters: { agents: ['codex', 'qoder'] }, + within + }) + ).toMatchObject({ hits: [{ agent: 'codex' }] }) + expect(call).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ + filters: { agents: ['codex'] }, + within + }) + ) + call.mockClear() + expect( + await client.searchSessions({ query: 'proof', filters: { agents: ['qoder'] }, within }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledWith('aiVault.searchStatus', {}) + if (ref.startsWith('b49')) { + expect( + await client.searchSessions({ query: 'proof', filters: { agents: [...AI_VAULT_AGENTS] } }) + ).toMatchObject({ hits: [{ agent: 'codex' }] }) + expect(call).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ + filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + }) + ) + } + } +)