Add Qoder session history and search (#24614)

* Add Qoder session history and search with real CLI coverage

* Allow the real Qoder marker file to end with a newline

* Keep Qoder tool output out of history previews and search

* Keep Qoder search pages readable by older clients

* Verify persisted Qoder history after a real generated and resumed task

* Negotiate Qoder filters before searching an older execution host

* Combine search client imports for the CI plugin gate

* Keep the relay search oracle aligned with legacy agent filtering

* test(qoder): align search capability contracts and pin old-host fencing
This commit is contained in:
Neil
2026-10-02 17:23:11 -07:00
committed by GitHub
parent 65deb4bdbe
commit f97ca2a49d
32 changed files with 985 additions and 28 deletions
+4 -1
View File
@@ -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') {
+9 -2
View File
@@ -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<string, unknown>][] = [
@@ -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 () => {
+7 -1
View File
@@ -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)')
@@ -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 },
{
@@ -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
@@ -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()
@@ -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<AiVaultSearchStatus> {
AiVaultSearchStatusRequestSchema.parse(raw)
return redactStatusForTransport(
AiVaultSearchStatusSchema.parse(
service ? await service.status() : unavailableSessionSearchStatus()
),
AiVaultSearchStatusSchema.parse({
...(service ? await service.status() : unavailableSessionSearchStatus()),
supportsQoderHistory: true
}),
transport
)
}
@@ -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}
@@ -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',
@@ -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<AiVaultSession | null> {
switch (candidate.agent) {
case 'qoder':
return parseQoderSessionFile(candidate.file, platform, messages)
case 'codebuddy':
return parseCodebuddySessionFile(candidate.file, platform, messages)
case 'claude':
@@ -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,
@@ -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(
@@ -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':
@@ -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<string> | Iterable<string>,
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)
}
@@ -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)
})
@@ -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'),
@@ -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
+4 -2
View File
@@ -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' } }]
+187
View File
@@ -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<typeof Os>()
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<void>((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<void>((resolve) => server.close(() => resolve()))
await rm(sandbox.home, { recursive: true, force: true })
sandbox.home = ''
}
},
180000
)
@@ -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) => {
+9 -1
View File
@@ -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()
@@ -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(
@@ -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',
+1
View File
@@ -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.",
@@ -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')
@@ -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' }
)
+1
View File
@@ -218,6 +218,7 @@ function buildAgentResumeInvocation(
return `${baseCommand} resume ${sessionArg}`
case 'cline':
return `${baseCommand} --id ${sessionArg}`
case 'qoder':
case 'codebuddy':
case 'claude':
case 'zcode':
+13 -1
View File
@@ -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' }
+5 -2
View File
@@ -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(),
@@ -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', {})
})
})
+2
View File
@@ -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',
@@ -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<string, unknown>) => {
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') }
})
)
}
}
)