diff --git a/apps/desktop/src/main/__tests__/multi-host-recall-search.test.ts b/apps/desktop/src/main/__tests__/multi-host-recall-search.test.ts new file mode 100644 index 0000000000..a31c8ac125 --- /dev/null +++ b/apps/desktop/src/main/__tests__/multi-host-recall-search.test.ts @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import { + collectRecallResponses, + createRecallSearchClient, + type RecallSearchError, + type RecallSearchPassage, +} from '../../preload/multi-host-recall-search.js'; + +function passage(sessionId: string, anchor: string): RecallSearchPassage { + return { + sessionId, + sessionTitle: sessionId, + anchorMessageId: anchor, + sequence: 0, + messages: [{ messageId: anchor, role: 'user', matchKind: 'user_message', text: anchor, timestamp: 1, isAnchor: true }], + matchedTerms: [], + score: 1, + }; +} + +function hostResponse(passages: RecallSearchPassage[], overrides: Partial<{ + gaps: string; + searchedEverySession: boolean; +}> = {}): unknown { + return { + ok: true, + result: { + ok: true, + passages, + gaps: overrides.gaps ?? '', + searchedEverySession: overrides.searchedEverySession ?? true, + }, + }; +} + +function fulfilled(value: unknown): PromiseSettledResult { + return { status: 'fulfilled', value }; +} + +function rejected(reason: unknown): PromiseSettledResult { + return { status: 'rejected', reason }; +} + +function failure(reason: string, message: string): unknown { + return { ok: false, reason, message }; +} + +test('interleaves Hosts so one large corpus cannot fill the window', () => { + const merged = collectRecallResponses( + [ + fulfilled(hostResponse([passage('a', 'a1'), passage('a', 'a2')])), + fulfilled(hostResponse([passage('b', 'b1'), passage('b', 'b2')])), + ], + 3, + ); + assert.deepEqual( + (merged as unknown as { passages: RecallSearchPassage[] }).passages.map((it) => it.anchorMessageId), + ['a1', 'b1', 'a2'], + ); +}); + +test('a Host that failed contributes nothing rather than failing the search', () => { + // One machine being down must not hide the history on the others. + const merged = collectRecallResponses( + [ + rejected(new Error('Host A unavailable')), + fulfilled(hostResponse([passage('b', 'b1')])), + ], + 10, + ); + assert.deepEqual((merged as unknown as { passages: RecallSearchPassage[] }).passages.map((it) => it.sessionId), ['b']); +}); + +test('reports a Host failure when every Host failed, without inventing a result', () => { + assert.deepEqual( + collectRecallResponses([fulfilled(failure('incognito_active', 'privacy'))], 10), + { ok: false, reason: 'incognito_active', message: 'privacy' }, + ); + assert.deepEqual(collectRecallResponses([rejected(new Error('down'))], 10), { + ok: false, + reason: 'provider_error', + message: 'No Runtime Host is available for search', + }); +}); + +test('a malformed Host payload is not mistaken for a success', () => { + const merged = collectRecallResponses( + [{ status: 'fulfilled', value: { ok: true, result: { ok: true } } }, { status: 'fulfilled', value: 'nonsense' }], + 10, + ); + assert.equal((merged as RecallSearchError).ok, false); +}); + +test('gaps describe one corpus only, and a single Host keeps its full-scan flag', () => { + const merged = collectRecallResponses( + [fulfilled(hostResponse([passage('a', 'a1')], { gaps: 'Searched 2 Sessions.', searchedEverySession: false }))], + 10, + ) as unknown as { gaps: string; searchedEverySession: boolean }; + assert.equal(merged.gaps, 'Searched 2 Sessions.'); + assert.equal(merged.searchedEverySession, false); +}); + +test('across several Hosts the envelope is complete only if every Host scanned fully', () => { + const merged = collectRecallResponses( + [ + fulfilled(hostResponse([passage('a', 'a1')], { searchedEverySession: true })), + fulfilled(hostResponse([passage('b', 'b1')], { searchedEverySession: false })), + ], + 10, + ) as unknown as { gaps: string; searchedEverySession: boolean }; + assert.equal(merged.searchedEverySession, false); + // A single Host's gap sentence would describe a corpus the user did not ask + // about separately, so a merged envelope says nothing instead of guessing. + assert.equal(merged.gaps, ''); +}); + +test('cancelling before Host discovery never dispatches the abandoned search', async () => { + const scopes = deferred(); + const calls: string[] = []; + const client = createRecallSearchClient({ + scopes: () => scopes.promise, + search: async (scope: string) => { + calls.push(scope); + return hostResponse([]); + }, + cancel: async () => {}, + }); + const task = client.recall({ terms: ['old'] }, 'old'); + await client.cancelRecall('old'); + assert.deepEqual(await task, { ok: false, reason: 'aborted', message: 'History search was aborted.' }); + scopes.resolve(['a', 'b']); + await Promise.resolve(); + assert.deepEqual(calls, []); +}); + +test('cancelling reaches every dispatched Host without waiting for results', async () => { + const started = deferred(); + const cancelled: string[] = []; + let count = 0; + const client = createRecallSearchClient({ + scopes: async () => ['a', 'b'], + search: async () => { + if ((count += 1) === 2) started.resolve(); + return new Promise(() => {}); + }, + cancel: async (scope: string, requestId: string) => { + cancelled.push(`${scope}:${requestId}`); + }, + }); + const task = client.recall({ terms: ['old'] }, 'old'); + await started.promise; + await client.cancelRecall('old'); + assert.equal((await task as RecallSearchError).reason, 'aborted'); + assert.deepEqual(cancelled, ['a:old', 'b:old']); + await client.cancelRecall('old'); + assert.equal(cancelled.length, 2, 'a second cancellation must not re-dispatch'); +}); + +test('the same request identity cannot run twice at once', async () => { + const client = createRecallSearchClient({ + scopes: async () => ['a'], + search: () => new Promise(() => {}), + cancel: async () => {}, + }); + void client.recall({ terms: ['x'] }, 'dup'); + await assert.rejects(() => client.recall({ terms: ['y'] }, 'dup'), /already active/); +}); diff --git a/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts b/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts deleted file mode 100644 index bbe8a8bdf6..0000000000 --- a/apps/desktop/src/main/__tests__/multi-host-thread-search.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { SearchError, SearchResult } from '@maka/core/search'; -import { deferred } from '@maka/core/test-only/async-primitives'; -import { collectThreadSearchResponses, createThreadSearchClient } from '../../preload/multi-host-thread-search.js'; - -const RESULT: SearchResult = { - source: 'thread', - title: 'Match', -}; -const ERROR: SearchError = { - ok: false, - reason: 'provider_error', - message: 'Host A failed', -}; - -function result(title: string): SearchResult { - return { source: 'thread', title }; -} - -test('preserves total multi-Host search failure without discarding partial success', async () => { - await assert.rejects( - collectThreadSearchResponses( - [ - Promise.reject(new Error('Host A unavailable')), - Promise.reject(new Error('Host B unavailable')), - ], - 10, - ), - /Host A unavailable/, - ); - - assert.deepEqual( - await collectThreadSearchResponses( - [Promise.reject(new Error('Host A unavailable')), Promise.resolve([RESULT])], - 10, - ), - [RESULT], - ); - - assert.deepEqual( - await collectThreadSearchResponses([Promise.resolve(ERROR)], 10), - ERROR, - ); -}); - -test('shares a bounded result window across ready Hosts', async () => { - assert.deepEqual( - await collectThreadSearchResponses( - [ - Promise.resolve([result('A1'), result('A2')]), - Promise.resolve([result('B1'), result('B2')]), - ], - 3, - ), - [result('A1'), result('B1'), result('A2')], - ); -}); - -test('canceling before Host discovery finishes never dispatches the abandoned search', async () => { - const scopes = deferred(); - const calls: string[] = []; - const client = createThreadSearchClient({ - scopes: () => scopes.promise, - search: async (scope: string) => { calls.push(scope); return []; }, - cancel: async () => {}, - }); - const task = client.thread({ source: 'thread', query: 'old', limit: 10 }, 'old'); - await client.cancelThread('old'); - assert.deepEqual(await task, { ok: false, reason: 'aborted', message: 'History search was aborted.' }); - scopes.resolve(['a', 'b']); - await Promise.resolve(); - assert.deepEqual(calls, []); -}); - -test('canceling a multi-Host query reaches every dispatched Host without waiting for search results', async () => { - const started = deferred(); - const cancelled: string[] = []; - let count = 0; - const client = createThreadSearchClient({ - scopes: async () => ['a', 'b'], - search: async () => { if (++count === 2) started.resolve(); return new Promise(() => {}); }, - cancel: async (scope, requestId) => { cancelled.push(`${scope}:${requestId}`); }, - }); - const task = client.thread({ source: 'thread', query: 'old', limit: 10 }, 'old'); - await started.promise; - await client.cancelThread('old'); - assert.equal((await task as SearchError).reason, 'aborted'); - assert.deepEqual(cancelled, ['a:old', 'b:old']); - await client.cancelThread('old'); - assert.equal(cancelled.length, 2); -}); diff --git a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts index 982806e79f..e6f0fae635 100644 --- a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts @@ -244,7 +244,7 @@ describe('Overlays feature boundary', () => { const violations: string[] = []; for (const path of productionRendererSources()) { const analysis = analysisOf(path); - for (const capability of ['window.maka.search.thread', 'window.maka.search.*']) { + for (const capability of ['window.maka.search.recall', 'window.maka.search.*']) { if ((analysis.bridgePaths[capability] ?? 0) > 0) { violations.push(`${relativeSource(path)}: ${capability}`); } diff --git a/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts b/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts index 081615c5b2..5194a81a55 100644 --- a/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts @@ -174,23 +174,23 @@ describe('OverlaysRoot', () => { const cancelled: string[] = []; const services = createFakeOverlaysServices({ search: { - thread: async (request, requestId) => { - queries.push([request.query, requestId]); - return []; + recall: async (request, requestId) => { + queries.push([request.terms.join(' '), requestId]); + return { passages: [], gaps: '', searchedEverySession: true }; }, - cancelThread: async (requestId) => { cancelled.push(requestId); }, + cancelRecall: async (requestId) => { cancelled.push(requestId); }, }, }); await act(async () => renderRoot(root, services)); const commands = latest!.commands; - await commands.searchThread({ query: 'deploy' } as Parameters< - OverlaysShellProjection['commands']['searchThread'] + await commands.searchRecall({ terms: ['deploy'] } as Parameters< + OverlaysShellProjection['commands']['searchRecall'] >[0], 'search-1'); await act(async () => commands.openSearch()); await act(async () => commands.closeSearch()); - assert.equal(latest!.commands.searchThread, commands.searchThread); - assert.equal(latest!.commands.cancelSearchThread, commands.cancelSearchThread); - await commands.cancelSearchThread('search-1'); + assert.equal(latest!.commands.searchRecall, commands.searchRecall); + assert.equal(latest!.commands.cancelSearchRecall, commands.cancelSearchRecall); + await commands.cancelSearchRecall('search-1'); assert.deepEqual(queries, [['deploy', 'search-1']]); assert.deepEqual(cancelled, ['search-1']); await act(async () => root.unmount()); diff --git a/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts b/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts index ad8c19a41d..a53e030e99 100644 --- a/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts @@ -22,7 +22,10 @@ import test from 'node:test'; import { act, createElement } from 'react'; import { parseHTML } from 'linkedom'; import { deferred } from '@maka/core/test-only/async-primitives'; -import type { SearchRequest, SearchResult } from '@maka/core/search'; +import type { + RecallSearchOutcome, + RecallSearchRequest, +} from '@maka/ui'; // Cover the complete overlay owner -> modal -> Desktop service path. // A rebase must preserve request identity and cancellation from #5256. @@ -53,18 +56,18 @@ test('overlay search preserves cancellation across supersession, close, reopen, await import('../../renderer/features/overlays/index.js'); const { createDesktopOverlaysServices } = await import('../../renderer/platform/desktop/create-overlays-services.js'); - const requests: ReturnType>[] = []; + const requests: ReturnType>[] = []; const requestIds: string[] = []; const cancelled: string[] = []; const search = { - thread: (_request: SearchRequest, requestId?: string) => { + recall: (_request: RecallSearchRequest, requestId?: string) => { assert.ok(requestId); requestIds.push(requestId); - const request = deferred(); + const request = deferred(); requests.push(request); return request.promise; }, - cancelThread: async (requestId: string) => { cancelled.push(requestId); }, + cancelRecall: async (requestId: string) => { cancelled.push(requestId); }, }; const root = createRoot(document.getElementById('root')!); let overlays: import('../../renderer/features/overlays/testing.js').OverlaysShellProjection; @@ -105,15 +108,35 @@ test('overlay search preserves cancellation across supersession, close, reopen, await type('older'); await type('maka'); assert.equal(requests.length, 3); - await act(async () => { requests[2]!.resolve([ - { source: 'thread', title: 'Latest maka match', target: { kind: 'thread', sessionId: 'latest' } }, - ]); }); + await act(async () => { requests[2]!.resolve({ + passages: [{ + sessionId: 'latest', + sessionTitle: 'Latest maka match', + anchorMessageId: 'latest-anchor', + sequence: 0, + messages: [{ + messageId: 'latest-anchor', + role: 'assistant', + matchKind: 'assistant_message', + text: 'Latest maka match', + timestamp: 1, + isAnchor: true, + }], + matchedTerms: ['maka'], + score: 1, + }], + gaps: '', + searchedEverySession: true, + }); }); assert.match(document.body.textContent ?? '', /Latest maka match/); assert.equal(busy(), 0, 'completed results must not wait for the superseded request'); assert.equal(input().value, 'maka'); assert.deepEqual(cancelled, [requestIds[0], requestIds[1]]); - await act(async () => { requests[0]!.resolve([]); requests[1]!.resolve([]); }); + await act(async () => { + requests[0]!.resolve({ passages: [], gaps: '', searchedEverySession: true }); + requests[1]!.resolve({ passages: [], gaps: '', searchedEverySession: true }); + }); assert.match(document.body.textContent ?? '', /Latest maka match/); assert.equal(busy(), 0); diff --git a/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts b/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts index 5b270a530f..627a7395ad 100644 --- a/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts @@ -46,11 +46,11 @@ function recordingEnvironment() { test('the Desktop adapter hands the search namespace through and owns the browser edges', async () => { const calls: string[] = []; const search = { - thread: async (request: { query: string }, requestId?: string) => { - calls.push(`thread:${request.query}:${requestId}`); - return []; + recall: async (request: { terms: readonly string[] }, requestId?: string) => { + calls.push(`recall:${request.terms.join(',')}:${requestId}`); + return { passages: [], gaps: '', searchedEverySession: true }; }, - cancelThread: async (requestId: string) => { calls.push(`cancel:${requestId}`); }, + cancelRecall: async (requestId: string) => { calls.push(`cancel:${requestId}`); }, }; const bridge = { search } as unknown as DesktopOverlaysBridge; const { environment, writes, blurred } = recordingEnvironment(); @@ -58,12 +58,12 @@ test('the Desktop adapter hands the search namespace through and owns the browse const services = createDesktopOverlaysServices(bridge, environment); assert.equal(services.search, bridge.search); - await services.search.thread({ query: 'plan' } as Parameters[0], 'search-1'); - await services.search.cancelThread('search-1'); + await services.search.recall({ terms: ['plan'] }, 'search-1'); + await services.search.cancelRecall('search-1'); services.settingsSection.persist('models'); services.focus.blurActiveElement(); - assert.deepEqual(calls, ['thread:plan:search-1', 'cancel:search-1']); + assert.deepEqual(calls, ['recall:plan:search-1', 'cancel:search-1']); assert.deepEqual(writes, [[SETTINGS_SECTION_STORAGE_KEY, 'models']]); assert.equal(SETTINGS_SECTION_STORAGE_KEY, 'maka-settings-section-v1'); assert.equal(blurred(), 1); @@ -71,7 +71,12 @@ test('the Desktop adapter hands the search namespace through and owns the browse }); test('the adapter tolerates an unavailable store and a missing active element', () => { - const bridge = { search: { thread: async () => [] } } as unknown as DesktopOverlaysBridge; + const bridge = { + search: { + recall: async () => ({ passages: [], gaps: '', searchedEverySession: true }), + cancelRecall: async () => undefined, + }, + } as unknown as DesktopOverlaysBridge; const services = createDesktopOverlaysServices(bridge, { storage: { setItem() { diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts index 3c6d5f7926..fe6d984a98 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-guest-ipc-preload.test.ts @@ -62,11 +62,11 @@ test('onboarding and workspace search never fan out Owner IPC to a ready Guest', }, }]; case 'sessions:list': return []; - case 'search:thread': + case 'search:recall': searchRequestId = requestId; searchStarted.resolve(); return pendingSearch.promise; - case 'search:thread:cancel': + case 'search:recall:cancel': cancelRequestId = payload; return; default: throw new Error('Unexpected channel: ' + channel); @@ -94,14 +94,14 @@ test('onboarding and workspace search never fan out Owner IPC to a ready Guest', assert.equal(snapshot.sessions.length, 1); assert.equal(snapshot.sessions[0]!.shared, true); assert.equal(snapshot.sessions[0]!.name, 'Shared Session'); - const search = bridge.search.thread({ query: 'hello', limit: 10, source: 'thread' }, 'search-owner'); + const search = bridge.search.recall({ terms: ['hello'], limit: 10 }, 'search-owner'); await searchStarted.promise; - await bridge.search.cancelThread('search-owner'); + await bridge.search.cancelRecall('search-owner'); assert.equal((await search as { reason: string }).reason, 'aborted'); assert.equal(searchRequestId, 'search-owner'); assert.equal(cancelRequestId, searchRequestId); assert.equal(calls.some(call => call.hostId === guest.hostId), false); - for (const channel of ['onboarding:getSnapshot', 'search:thread', 'search:thread:cancel']) { + for (const channel of ['onboarding:getSnapshot', 'search:recall', 'search:recall:cancel']) { assert.equal(calls.filter(call => call.channel === channel && call.hostId === owner.hostId).length, 1); } }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-recall-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-recall-ipc-main.test.ts new file mode 100644 index 0000000000..7a4efb4f59 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-recall-ipc-main.test.ts @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { IpcHandler } from '../../main/ipc-reconnect-policy.js'; +import { registerRuntimeHostRecallIpc } from '../runtime-host-recall-ipc-main.js'; + +/** + * The recall IPC is a relay: the scan happens in the Host, so this layer only + * forwards the request, bounds the wait, and relays the envelope. What is + * worth pinning is therefore the relay's own behavior — what it refuses, what + * it answers when the Host is gone, and that an abandoned request stops being + * ours to wait on. + */ + +type RecallClient = Parameters[0]['client']; + +function successEnvelope(passages: readonly unknown[] = []): unknown { + return { + ok: true, + result: { ok: true, passages, gaps: 'Searched 1 Session(s).', searchedEverySession: true }, + }; +} + +function register(client: RecallClient) { + const handlers = new Map(); + registerRuntimeHostRecallIpc({ + ipcMain: { + handle: (channel, listener) => { + handlers.set(channel, listener); + }, + handleReconnectableRead: (channel, listener) => { + handlers.set(channel, listener); + }, + }, + client, + }); + const handler = handlers.get('search:recall'); + const cancel = handlers.get('search:recall:cancel'); + assert.ok(handler, 'search:recall must be registered'); + assert.ok(cancel, 'search:recall:cancel must be registered'); + return { handler, cancel }; +} + +/** + * An IPC event stand-in. The handler reads `event.sender` and subscribes to + * its lifecycle, so the stub is an object carrying a sender that records the + * listeners the handler attaches and can fire them. + */ +function ipcEvent(): { event: unknown; emit(event: string): void } { + const listeners = new Map void)[]>(); + const sender = { + once(event: string, listener: () => void) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + }, + removeListener(event: string, listener: () => void) { + listeners.set(event, (listeners.get(event) ?? []).filter((it) => it !== listener)); + }, + }; + return { + event: { sender }, + emit(event: string) { + listeners.get(event)?.forEach((listener) => listener()); + }, + }; +} + +test('relays the Host envelope unchanged, including the navigation coordinate', async () => { + const passage = { + sessionId: 'session-1', + sessionTitle: 'deploy notes', + anchorMessageId: 'message-7', + sequence: 7, + messages: [], + matchedTerms: ['deploy'], + score: 1, + }; + const seen: unknown[] = []; + const { handler } = register({ + queryRecall: async (input: never) => { + seen.push(input); + return successEnvelope([passage]) as never; + }, + }); + const { event } = ipcEvent(); + const response = (await handler( + event as never, + { terms: ['deploy'], limit: 5 }, + 'request-1', + )) as { ok: true; result: { passages: { sequence: number }[] } }; + assert.deepEqual(seen, [{ terms: ['deploy'], limit: 5 }]); + assert.equal(response.ok, true); + assert.equal(response.result.passages[0]?.sequence, 7); +}); + +test('refuses a malformed request identity without asking the Host', async () => { + let asked = 0; + const { handler } = register({ + queryRecall: async () => { + asked += 1; + return successEnvelope() as never; + }, + }); + const { event } = ipcEvent(); + for (const requestId of ['', 'x'.repeat(129)]) { + assert.deepEqual(await handler(event as never, { terms: ['a'] }, requestId), { + ok: false, + reason: 'invalid_query', + message: 'Invalid search request identity.', + }); + } + assert.equal(asked, 0, 'an unroutable request must not reach the Host'); +}); + +test('an unavailable Host answers a search failure rather than throwing', async () => { + const { handler } = register({ + queryRecall: async () => { + throw new Error('Host is not connected'); + }, + }); + const { event } = ipcEvent(); + assert.deepEqual(await handler(event as never, { terms: ['deploy'] }, 'request-1'), { + ok: false, + reason: 'provider_error', + message: 'Runtime Host is unavailable for search', + }); +}); + +test('a cancellation abandons the wait and reports it as aborted', async () => { + const { handler, cancel } = register({ + // The Host owns the scan; this side only stops waiting on it. + queryRecall: () => new Promise(() => {}), + }); + const { event } = ipcEvent(); + const task = handler(event as never, { terms: ['deploy'] }, 'request-1'); + await cancel(event as never, 'request-1'); + assert.deepEqual(await task, { + ok: false, + reason: 'aborted', + message: 'History search was aborted.', + }); +}); + +test('a renderer that goes away stops its in-flight search', async () => { + const { handler } = register({ + queryRecall: () => new Promise(() => {}), + }); + const { event, emit: fire } = ipcEvent(); + const task = handler(event as never, { terms: ['deploy'] }, 'request-1'); + fire('render-process-gone'); + assert.deepEqual(await task, { + ok: false, + reason: 'aborted', + message: 'History search was aborted.', + }); +}); + +test('a superseded request on one sender is cancelled before the new one runs', async () => { + const cancelled: string[] = []; + const { handler } = register({ + queryRecall: () => new Promise(() => {}), + }); + const { event } = ipcEvent(); + const first = handler(event as never, { terms: ['old'] }, 'same-id'); + const second = handler(event as never, { terms: ['new'] }, 'same-id'); + // The first request is replaced in the pending map, so it must observe the + // rejection rather than hanging forever. + assert.deepEqual(await first, { + ok: false, + reason: 'aborted', + message: 'History search was aborted.', + }); + assert.deepEqual(cancelled, []); + void second; +}); + +test('a request without an identity is still answered', async () => { + const reached = deferred(); + const { handler } = register({ + queryRecall: async () => { + reached.resolve(); + return successEnvelope() as never; + }, + }); + const { event } = ipcEvent(); + const response = await handler(event as never, { terms: ['deploy'] }); + await reached.promise; + assert.equal((response as { ok?: unknown }).ok, true); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts deleted file mode 100644 index 714ead4d8c..0000000000 --- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts +++ /dev/null @@ -1,453 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { EventEmitter } from 'node:events'; -import { deferred } from '@maka/core/test-only/async-primitives'; -import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; -import type { StoredMessage } from '@maka/core/session'; -import type { SearchError, SearchResult } from '@maka/core/search'; -import type { SessionCatalogProjection } from '@maka/runtime-host/protocol'; -import type { IpcHandler, ReconnectableReadIpcMain } from '../ipc-reconnect-policy.js'; -import type { DesktopRuntimeHostClient } from '../runtime-host-client.js'; -import { registerRuntimeHostSearchIpc } from '../runtime-host-search-ipc-main.js'; -import { RuntimeHostReconnectingIpcMain } from '../runtime-host-reconnecting-ipc-main.js'; -import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; -import { createThreadSearchClient } from '../../preload/multi-host-thread-search.js'; - -// The catalog hands search a composed Desktop key, not a bare Runtime Host id. -// Naming it here is what makes the passthrough in runtime-host-search-ipc-main -// observable: a hit whose target carried the bare id would open nothing on a -// second Host. -const SEARCHABLE_SESSION = desktopSessionKey({ - hostId: 'host-b', - sessionId: 'searchable-session', -}); - -test('Runtime Host transcripts produce title and content hits with turn ids', async () => { - const handlers = new Map(); - let closed = 0; - registerRuntimeHostSearchIpc({ - ipcMain: { - handle: (channel, listener) => { - handlers.set(channel, listener); - }, - handleReconnectableRead: (channel, listener) => { - handlers.set(channel, listener); - }, - }, - client: searchClient({ - listSessions: async () => [catalogSession(SEARCHABLE_SESSION, '长对话提示词导航示例')], - openSession: async () => - ({ - // Three earlier messages so the hit's `sequence` is its real position - // in the transcript. With a single message every projection, correct - // or not, reports 0. - loadTranscript: async () => [ - { type: 'user', id: 'host-user-0', turnId: 'turn-host-0', ts: 1, text: '第 0 个问题' }, - { type: 'assistant', id: 'host-reply-0', turnId: 'turn-host-0', ts: 2, text: '回答 0' }, - { type: 'user', id: 'host-user-1', turnId: 'turn-host-1', ts: 3, text: '第 1 个问题' }, - { - type: 'user', - id: 'host-user', - turnId: 'turn-host-3', - ts: 4, - text: '第 3 个问题:这一段的调用链路是怎样的?', - }, - ], - close: async () => { - closed += 1; - }, - }) as never, - }), - }); - - const handler = handlers.get('search:thread'); - assert.ok(handler); - const titleHits = expectResults( - await handler({} as never, { - source: 'thread', - query: '长对话', - limit: 10, - }), - ); - assert.equal(titleHits[0]?.summary, '任务标题'); - assert.deepEqual(titleHits[0]?.target, { - kind: 'thread', - sessionId: SEARCHABLE_SESSION, - }); - - const contentHits = expectResults( - await handler({} as never, { - source: 'thread', - query: '第 3 个问题', - limit: 10, - }), - ); - assert.equal(contentHits.length, 1); - assert.equal(contentHits[0]?.summary, '用户消息'); - assert.deepEqual(contentHits[0]?.target, { - kind: 'thread', - sessionId: SEARCHABLE_SESSION, - turnId: 'turn-host-3', - sequence: 3, - }); - assert.equal(closed, 2); -}); - -test('a Runtime Host transcript failure yields no content hit', async () => { - const handlers = new Map(); - registerRuntimeHostSearchIpc({ - ipcMain: { - handle: (channel, listener) => { - handlers.set(channel, listener); - }, - handleReconnectableRead: (channel, listener) => { - handlers.set(channel, listener); - }, - }, - client: searchClient({ - listSessions: async () => [catalogSession('searchable-session', '长对话提示词导航示例')], - openSession: async () => { - throw new Error('Host transcript unavailable'); - }, - }), - }); - - const handler = handlers.get('search:thread'); - assert.ok(handler); - assert.deepEqual( - await handler({} as never, { - source: 'thread', - query: '第 3 个问题', - limit: 10, - }), - [], - ); -}); - -test('canceling a search closes its transcript and stops reading further sessions', async () => { - const handlers = new Map(); - const firstRead = deferred(); - const transcript = deferred(); - const opened: string[] = []; - let closed = 0; - registerRuntimeHostSearchIpc({ - ipcMain: { - handle: (channel, listener) => { handlers.set(channel, listener); }, - handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); }, - }, - client: searchClient({ - listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')], - openSession: async (id) => { - opened.push(id); - return { - loadTranscript: () => { firstRead.resolve(); return transcript.promise; }, - close: async () => { closed += 1; transcript.resolve([]); }, - } as never; - }, - }), - }); - const sender = new EventEmitter(); - const event = { sender } as Parameters[0]; - const search = handlers.get('search:thread')!; - const task = search(event, { source: 'thread', query: 'missing', limit: 10 }, 'first'); - await firstRead.promise; - const cancel = handlers.get('search:thread:cancel'); - assert.ok(cancel, 'Desktop must expose cancellation to the preload'); - // Another window cannot cancel this window's request, even with its id. - await cancel({ sender: new EventEmitter() } as Parameters[0], 'first'); - assert.equal(closed, 0); - await cancel(event, 'first'); - const outcome = await task; - assert.equal(outcome.reason, 'aborted'); - assert.deepEqual(opened, ['a']); - assert.equal(closed, 1); - assert.equal(sender.listenerCount('destroyed'), 0); - assert.equal(sender.listenerCount('render-process-gone'), 0); -}); - -test('a canceled search is not replayed on a replacement Host candidate', async (t) => { - const handlers = new Map(); - const router = new RuntimeHostReconnectingIpcMain({ - handle: (channel, listener) => { handlers.set(channel, listener); }, - removeHandler: (channel) => { handlers.delete(channel); }, - }); - t.after(() => router.close()); - const event = { sender: new EventEmitter() } as Parameters[0]; - const scope = { hostId: 'host', targetEpoch: 'epoch' }; - const started = deferred(); - const transcript = deferred(); - let opened = 0; - const client = searchClient({ - listSessions: async () => [catalogSession('a', 'First')], - openSession: async () => { - opened += 1; - return { - loadTranscript: () => { started.resolve(); return transcript.promise; }, - close: async () => {}, - } as never; - }, - }); - const registerCandidate = () => { - const target = router.createTarget('epoch'); - const scoped = (listener: IpcHandler): IpcHandler => - (event, _scope, ...args) => listener(event, ...args); - const ipcMain: ReconnectableReadIpcMain = { - handle: (channel, listener) => target.handle(channel, scoped(listener)), - handleReconnectableRead: (channel, listener) => target.handleReconnectableRead!(channel, scoped(listener)), - }; - registerRuntimeHostSearchIpc({ ipcMain, client }); - target.completeRegistration(); - return target; - }; - const first = registerCandidate(); - router.activate('epoch'); - const task = handlers.get('search:thread')!(event, scope, - { source: 'thread', query: 'missing', limit: 10 }, 'old'); - await started.promise; - await handlers.get('search:thread:cancel')!(event, scope, 'old'); - first.removeHandler('search:thread'); - first.removeHandler('search:thread:cancel'); - registerCandidate(); - transcript.resolve([]); - const outcome = await task; - assert.equal(opened, 1, 'reconnecting must not revive a canceled transcript scan'); - assert.equal(outcome.reason, 'aborted'); -}); - -for (const lifecycleEvent of ['destroyed', 'render-process-gone'] as const) { - test(`${lifecycleEvent} stops a pending search before reading its opening transcript`, async () => { - const handlers = new Map(); - const opening = deferred(); - const started = deferred(); - const opened: string[] = []; - let closed = 0; - let read = 0; - registerRuntimeHostSearchIpc({ - ipcMain: { - handle: (channel, listener) => { handlers.set(channel, listener); }, - handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); }, - }, - client: searchClient({ - listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')], - openSession: async (id) => { opened.push(id); started.resolve(); return opening.promise; }, - }), - }); - const sender = new EventEmitter(); - const task = handlers.get('search:thread')!({ sender } as Parameters[0], - { source: 'thread', query: 'missing', limit: 10 }, 'request'); - await started.promise; - sender.emit(lifecycleEvent, {}, { reason: 'crashed', exitCode: 1 }); - opening.resolve({ - loadTranscript: async () => { read += 1; return []; }, - close: async () => { closed += 1; }, - } as never); - assert.equal((await task).reason, 'aborted'); - assert.deepEqual(opened, ['a']); - assert.equal(read, 0); - assert.equal(closed, 1); - assert.equal(sender.listenerCount('destroyed'), 0); - assert.equal(sender.listenerCount('render-process-gone'), 0); - }); -} - -test('renderer crash closes an in-flight search and allows a new search on the same WebContents', async () => { - const handlers = new Map(); - const started = deferred(); - const transcript = deferred(); - const opened: string[] = []; - const closed: string[] = []; - registerRuntimeHostSearchIpc({ - ipcMain: { - handle: (channel, listener) => { handlers.set(channel, listener); }, - handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); }, - }, - client: searchClient({ - listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')], - openSession: async (id) => { - opened.push(id); - const abandoned = opened.length === 1; - return { - loadTranscript: async () => { - if (abandoned) { started.resolve(); return transcript.promise; } - return [{ type: 'user', id: 'message', turnId: 'turn', ts: 1, text: 'latest match' }]; - }, - close: async () => { closed.push(id); }, - } as never; - }, - }), - }); - const sender = new EventEmitter(); - const event = { sender } as Parameters[0]; - const search = handlers.get('search:thread')!; - const abandoned = search(event, { source: 'thread', query: 'missing', limit: 10 }, 'old'); - await started.promise; - sender.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 1 }); - assert.deepEqual(closed, ['a'], 'a crash closes the transcript before its pending reply arrives'); - assert.equal(sender.listenerCount('destroyed'), 0); - assert.equal(sender.listenerCount('render-process-gone'), 0); - - // Recovery reloads the same WebContents while the abandoned read is pending. - const latest = await search(event, { source: 'thread', query: 'latest', limit: 10 }, 'new'); - assert.equal(expectResults(latest).length, 2); - assert.deepEqual(opened, ['a', 'a', 'b']); - assert.deepEqual(closed, ['a', 'a', 'b']); - assert.equal(sender.listenerCount('destroyed'), 0); - assert.equal(sender.listenerCount('render-process-gone'), 0); - - transcript.resolve([]); - assert.equal((await abandoned).reason, 'aborted'); - assert.deepEqual(opened, ['a', 'a', 'b'], 'a late reply must not resume the abandoned scan'); - assert.deepEqual(closed, ['a', 'a', 'b'], 'each search handle closes exactly once'); - sender.emit('destroyed'); - assert.deepEqual(closed, ['a', 'a', 'b']); -}); - -test('rapid replacement and dismissal stop each old scan while the latest query still completes', async () => { - const handlers = new Map(); - const sender = new EventEmitter(); - const event = { sender } as Parameters[0]; - const scans: Array<{ closed: number; page: ReturnType> }> = []; - let started = deferred(); - let completeLatest = false; - registerRuntimeHostSearchIpc({ - ipcMain: { - handle: (channel, listener) => { handlers.set(channel, listener); }, - handleReconnectableRead: (channel, listener) => { handlers.set(channel, listener); }, - }, - client: searchClient({ - listSessions: async () => [catalogSession('a', 'First'), catalogSession('b', 'Second')], - openSession: async () => { - const scan = { closed: 0, page: deferred() }; - scans.push(scan); - return { - loadTranscript: async () => { - started.resolve(); - if (completeLatest) return [ - { type: 'user', id: 'message', turnId: 'turn', ts: 1, text: 'latest match' }, - ]; - return scan.page.promise; - }, - close: async () => { scan.closed += 1; }, - } as never; - }, - }), - }); - const outcomes: Array> = []; - const client = createThreadSearchClient({ - scopes: async () => ['host'], - search: (_scope, request, requestId) => { - const task = Promise.resolve(handlers.get('search:thread')!(event, request, requestId)); - outcomes.push(task); - return task; - }, - cancel: async (_scope, requestId) => { - await handlers.get('search:thread:cancel')!(event, requestId); - }, - }); - for (let index = 0; index < 10; index += 1) { - started = deferred(); - const task = client.thread({ source: 'thread', query: `old-${index}`, limit: 10 }, `request-${index}`); - await started.promise; - await client.cancelThread(`request-${index}`); - const outcome = await task; - assert.equal(Array.isArray(outcome), false); - if (!Array.isArray(outcome)) assert.equal(outcome.reason, 'aborted'); - assert.equal(scans[index]!.closed, 1, 'cancellation closes a read even before its reply arrives'); - assert.equal(sender.listenerCount('destroyed'), 0, 'canceled reads release window listeners immediately'); - assert.equal(sender.listenerCount('render-process-gone'), 0, 'canceled reads release crash listeners immediately'); - } - assert.equal(scans.length, 10, 'each old query stops at its first transcript'); - - completeLatest = true; - const latest = await client.thread({ source: 'thread', query: 'latest', limit: 10 }, 'latest'); - assert.equal(expectResults(latest).length, 2); - assert.equal(scans.length, 12); - - // Replies for the abandoned reads can arrive after the new result. They - // must not resume scanning further sessions or close the handles twice. - for (const scan of scans.slice(0, 10)) scan.page.resolve([]); - await Promise.all(outcomes); - assert.equal(scans.length, 12); - assert.ok(scans.every((scan) => scan.closed === 1)); - assert.equal(sender.listenerCount('destroyed'), 0); - assert.equal(sender.listenerCount('render-process-gone'), 0); -}); - -function expectResults(outcome: unknown): Array<{ - summary?: string; - target?: { - kind: string; - sessionId: string; - turnId?: string; - sequence?: number; - }; -}> { - if (!Array.isArray(outcome)) { - assert.fail(`expected search results, got ${JSON.stringify(outcome)}`); - } - return outcome; -} - -function searchClient( - overrides: Partial>, -): Pick { - return { - listSessions: async () => [], - openSession: async () => { - throw new Error('openSession is not used by this test'); - }, - queryRuntimePolicy: async () => ({ - revision: 1, - policy: createDefaultRuntimePolicy(), - }), - ...overrides, - }; -} - -function catalogSession(id: string, name: string): SessionCatalogProjection { - return { - id, - revision: 1, - workspace: { - target: { kind: 'host_path', path: '/workspace' }, - hostCwd: '/workspace', - }, - createdAt: 1, - activityAt: 1, - lastMessageAt: 1, - name, - isFlagged: false, - isArchived: false, - labels: [], - labelsTruncated: false, - hasUnread: false, - status: 'active', - backend: 'ai-sdk', - llmConnectionId: 'connection-1', - llmConnectionSlug: 'zai-live', - connectionLocked: true, - model: 'glm-5.1', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - }; -} diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts deleted file mode 100644 index dc36bbcb28..0000000000 --- a/apps/desktop/src/main/__tests__/thread-search.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import { - SNIPPET_MAX_CODE_POINTS, - TOOL_RESULT_SCAN_CAP_BYTES, - capCodePoints, - collectSearchableText, - findMatch, - foldForMatch, - runThreadSearch, -} from '@maka/core/thread-search'; - -type Entry = { session: SessionSummary; messages: StoredMessage[] }; -type SearchOutcome = Awaited>; - -function session(overrides: Partial & { id: string }): SessionSummary { - return { - name: overrides.id, - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - status: 'active', - backend: 'ai-sdk', - llmConnectionSlug: 'anthropic', - connectionLocked: false, - model: 'claude-sonnet-4-5-20250929', - permissionMode: 'ask', - lastMessageAt: 1_700_000_000_000, - ...overrides, - }; -} - -function userMessage(text: string, turnId = 't1', id = 'u1'): StoredMessage { - return { type: 'user', id, turnId, ts: 1_700_000_000_000, text }; -} - -function assistantMessage( - text: string, - thinking?: string, - turnId = 't1', -): Extract { - return { - type: 'assistant', - id: 'a1', - turnId, - ts: 1_700_000_000_000, - text, - modelId: 'glm-4.7', - ...(thinking ? { thinking: { text: thinking } } : {}), - }; -} - -function toolCall(intent?: string): Extract { - return { - type: 'tool_call', - id: 'tc1', - turnId: 't1', - ts: 1_700_000_000_000, - toolName: 'Bash', - displayName: 'Shell command', - intent, - args: {}, - }; -} - -function toolResult(content: unknown, isError = false): Extract { - return { - type: 'tool_result', - id: 'tr1', - turnId: 't1', - ts: 1_700_000_000_000, - toolUseId: 'call1', - isError, - content: content as never, - }; -} - -function makeDeps(entries: Record, privacyPayload: unknown = { incognitoActive: false }) { - return { - async listSessions() { - return Object.values(entries).map((entry) => entry.session); - }, - async readMessages(sessionId: string) { - return entries[sessionId]?.messages ?? []; - }, - async getPrivacyContext() { - return privacyPayload; - }, - }; -} - -function expectResults(outcome: SearchOutcome) { - if (!outcome.ok) assert.fail(`expected results, got ${outcome.reason}`); - return outcome.results; -} - -describe('runThreadSearch', () => { - it('fails closed for malformed requests and unsupported sources', async () => { - const cases: Array<[unknown, 'invalid_query' | 'disabled']> = [ - [null, 'invalid_query'], - [undefined, 'invalid_query'], - ['hello', 'invalid_query'], - [[], 'invalid_query'], - [{ query: 'hello', limit: 5 }, 'disabled'], - [{ source: 'web', query: 'hello', limit: 5 }, 'disabled'], - [{ source: 'thread', limit: 5 }, 'invalid_query'], - [{ source: 'thread', query: 42, limit: 5 }, 'invalid_query'], - [{ source: 'thread', query: ' ', limit: 5 }, 'invalid_query'], - ]; - for (const [request, reason] of cases) { - const outcome = await runThreadSearch(request, makeDeps({})); - assert.equal(outcome.ok, false); - if (!outcome.ok) assert.equal(outcome.reason, reason); - } - }); - - it('clamps results to the shared maximum and marks truncation', async () => { - const entries: Record = {}; - for (let index = 0; index < 15; index++) { - const id = `s${String(index).padStart(2, '0')}`; - entries[id] = { - session: session({ id, lastMessageAt: 1_700_000_000_000 - index }), - messages: [userMessage('hello world')], - }; - } - const hits = expectResults( - await runThreadSearch({ source: 'thread', query: 'hello', limit: 50 }, makeDeps(entries)), - ); - assert.equal(hits.length, 10); - assert.equal(hits.at(-1)?.truncated, true); - }); - - it('continues beyond the session scan ceiling without gaps', async () => { - const entries: Record = {}; - for (let index = 0; index < 201; index += 1) { - const id = `session-${String(index).padStart(3, '0')}`; - entries[id] = { - // The stable id tie-breaker is part of the cursor contract. - session: session({ id, lastMessageAt: 10_000 }), - messages: index === 200 ? [userMessage('only-oldest-match')] : [], - }; - } - const first = await runThreadSearch( - { source: 'thread', query: 'only-oldest-match', limit: 5 }, - makeDeps(entries), - ); - assert.equal(first.ok, true); - if (!first.ok) return; - assert.deepEqual(first.results, []); - assert.equal(first.truncated, true); - assert.equal(typeof first.nextCursor, 'string'); - - const second = await runThreadSearch( - { - source: 'thread', - query: 'only-oldest-match', - limit: 5, - cursor: first.nextCursor, - }, - makeDeps(entries), - ); - assert.equal(second.ok, true); - if (!second.ok) return; - assert.deepEqual( - second.results.map((result) => - result.target?.kind === 'thread' ? result.target.sessionId : undefined, - ), - ['session-200'], - ); - assert.equal(second.truncated, false); - assert.equal(second.nextCursor, undefined); - - const mismatched = await runThreadSearch( - { source: 'thread', query: 'another-query', limit: 5, cursor: first.nextCursor }, - makeDeps(entries), - ); - assert.equal(mismatched.ok, false); - if (!mismatched.ok) assert.equal(mismatched.reason, 'invalid_query'); - }); - - it('checks cancellation between transcript reads', async () => { - const controller = new AbortController(); - let reads = 0; - const outcome = await runThreadSearch( - { source: 'thread', query: 'needle', limit: 5 }, - { - ...makeDeps({ - newest: { session: session({ id: 'newest', lastMessageAt: 2 }), messages: [] }, - older: { - session: session({ id: 'older', lastMessageAt: 1 }), - messages: [userMessage('needle')], - }, - }), - async readMessages(sessionId, signal) { - reads += 1; - assert.equal(signal, controller.signal); - if (sessionId === 'newest') controller.abort(); - return []; - }, - }, - { abortSignal: controller.signal }, - ); - assert.equal(outcome.ok, false); - if (!outcome.ok) assert.equal(outcome.reason, 'aborted'); - assert.equal(reads, 1); - }); - - it('yields to cancellation while scanning a large transcript', async () => { - const controller = new AbortController(); - const messages = Array.from({ length: 2_000 }, (_, index) => - userMessage(`ordinary message ${index}`, `turn-${index}`, `message-${index}`), - ); - setImmediate(() => controller.abort()); - const outcome = await runThreadSearch( - { source: 'thread', query: 'missing needle', limit: 5 }, - makeDeps({ large: { session: session({ id: 'large' }), messages } }), - { abortSignal: controller.signal }, - ); - assert.equal(outcome.ok, false); - if (!outcome.ok) assert.equal(outcome.reason, 'aborted'); - }); - - it('excludes the active Turn only inside the active Session', async () => { - const hits = expectResults( - await runThreadSearch( - { source: 'thread', query: 'copied text', limit: 10 }, - makeDeps({ - active: { - session: session({ id: 'active', lastMessageAt: 2 }), - messages: [userMessage('copied text active', 'shared-turn', 'active-message')], - }, - branch: { - session: session({ id: 'branch', lastMessageAt: 1 }), - messages: [userMessage('copied text branch', 'shared-turn', 'branch-message')], - }, - }), - { activeSessionId: 'active', excludeTurnIds: new Set(['shared-turn']) }, - ), - ); - assert.deepEqual( - hits.map((hit) => (hit.target?.kind === 'thread' ? hit.target.sessionId : undefined)), - ['branch'], - ); - }); - - it('redacts snippets and excludes fake-backend and archived sessions', async () => { - const hits = expectResults( - await runThreadSearch( - { source: 'thread', query: 'hello', limit: 5 }, - makeDeps({ - fake: { - session: session({ id: 'fake', backend: 'fake' }), - messages: [userMessage('hello from fixture')], - }, - // Archiving a task takes it out of the working set. It has no rail - // row to land on, so a hit inside it can only be opened from a - // surface that no longer exists; Settings manages it instead. - archived: { - session: session({ id: 'archived', isArchived: true }), - messages: [userMessage('hello from an archived task')], - }, - real: { - session: session({ id: 'real' }), - messages: [userMessage('hello sk-ant-test-secret-token-12345 world')], - }, - }), - ), - ); - assert.equal(hits.length, 1); - assert.equal(hits[0]?.target?.kind === 'thread' && hits[0].target.sessionId, 'real'); - assert.match(hits[0]?.snippet ?? '', /\[redacted\]/); - assert.equal(hits[0]?.snippet?.includes('sk-ant-test-secret-token-12345'), false); - }); - - it('matches only redacted projections and rejects secret-shaped queries', async () => { - const entries = { - title: { - session: session({ id: 'title', name: 'password=title-secret-value' }), - messages: [], - }, - message: { - session: session({ id: 'message' }), - messages: [userMessage('token=message-secret-value')], - }, - intent: { - session: session({ id: 'intent' }), - messages: [toolCall('api_key=intent-secret-value')], - }, - result: { - session: session({ id: 'result' }), - messages: [toolResult({ password: 'result-secret-value' })], - }, - }; - - for (const query of [ - 'title-secret-value', - 'title-wrong-value', - 'message-secret-value', - 'message-wrong-value', - 'intent-secret-value', - 'intent-wrong-value', - 'result-secret-value', - 'result-wrong-value', - ]) { - assert.deepEqual( - expectResults( - await runThreadSearch({ source: 'thread', query, limit: 10 }, makeDeps(entries)), - ), - [], - ); - } - - for (const query of [ - 'sk-ant-correctsecret12345678', - 'sk-ant-wrongsecret123456789', - ]) { - const outcome = await runThreadSearch( - { source: 'thread', query, limit: 5 }, - makeDeps(entries), - ); - assert.equal(outcome.ok, false); - if (!outcome.ok) assert.equal(outcome.reason, 'invalid_query'); - } - }); - - it('includes archived sessions only when the caller explicitly opts in', async () => { - const entries = { - archived: { - session: session({ id: 'archived', isArchived: true }), - messages: [userMessage('archived needle')], - }, - }; - assert.deepEqual( - expectResults( - await runThreadSearch( - { source: 'thread', query: 'archived needle', limit: 5 }, - makeDeps(entries), - ), - ), - [], - ); - const optedIn = expectResults( - await runThreadSearch( - { source: 'thread', query: 'archived needle', limit: 5 }, - makeDeps(entries), - { includeArchived: true }, - ), - ); - assert.equal(optedIn[0]?.target?.kind === 'thread' && optedIn[0].target.sessionId, 'archived'); - }); - - it('searches only the current committed conversation revision', async () => { - const hits = expectResults( - await runThreadSearch( - { source: 'thread', query: 'shared-match', limit: 10 }, - makeDeps({ - root: { - session: session({ id: 'root', lastMessageAt: 10 }), - messages: [userMessage('shared-match old version')], - }, - revision: { - session: session({ - id: 'revision', - revisionRootSessionId: 'root', - revisionParentSessionId: 'root', - revisionIndex: 2, - revisionState: 'committed', - lastMessageAt: 20, - }), - messages: [userMessage('shared-match current version')], - }, - preparing: { - session: session({ - id: 'preparing', - revisionRootSessionId: 'root', - revisionParentSessionId: 'revision', - revisionIndex: 3, - revisionState: 'preparing', - lastMessageAt: 30, - }), - messages: [userMessage('shared-match uncommitted version')], - }, - }), - ), - ); - assert.deepEqual( - hits.map((hit) => (hit.target?.kind === 'thread' ? hit.target.sessionId : undefined)), - ['revision'], - ); - }); - - it('returns navigable title and transcript results without synthetic URLs', async () => { - const entries = { - s1: { - session: session({ - id: 's1', - name: 'Maka roadmap sk-ant-test-secret-token-12345 planning', - }), - messages: [userMessage('diagnostic from user', 'turn-user')], - }, - }; - const titleHit = expectResults( - await runThreadSearch({ source: 'thread', query: 'roadmap', limit: 5 }, makeDeps(entries)), - )[0]!; - assert.deepEqual(titleHit.target, { - kind: 'thread', - sessionId: 's1', - matchKind: 'session_title', - }); - assert.equal(titleHit.summary, '任务标题'); - assert.equal(titleHit.url, undefined); - assert.match(titleHit.snippet ?? '', /\[redacted\]/); - assert.equal(titleHit.snippet?.includes('sk-ant-test-secret-token-12345'), false); - - const messageHit = expectResults( - await runThreadSearch({ source: 'thread', query: 'diagnostic', limit: 5 }, makeDeps(entries)), - )[0]!; - assert.deepEqual(messageHit.target, { - kind: 'thread', - sessionId: 's1', - turnId: 'turn-user', - sequence: 0, - messageId: 'u1', - matchKind: 'user_message', - messageTimestamp: 1_700_000_000_000, - }); - assert.equal(messageHit.summary, '用户消息'); - assert.equal(messageHit.url, undefined); - }); - - it('skips a transcript that its dependency could not read', async () => { - const entries = { - s1: { session: session({ id: 's1' }), messages: [] }, - }; - const deps = makeDeps(entries); - - assert.deepEqual( - expectResults( - await runThreadSearch( - { source: 'thread', query: 'diagnostic', limit: 5 }, - { ...deps, readMessages: async () => null }, - ), - ), - [], - ); - }); - - it('blocks active or unverifiable privacy state before scanning', async () => { - for (const privacyPayload of [ - { incognitoActive: true }, - null, - {}, - { incognitoActive: 'true' }, - 'invalid', - [], - ]) { - let listCalls = 0; - let readCalls = 0; - const base = makeDeps({}, privacyPayload); - const outcome = await runThreadSearch( - { source: 'thread', query: 'hello', limit: 5 }, - { - ...base, - async listSessions() { - listCalls++; - return []; - }, - async readMessages() { - readCalls++; - return []; - }, - }, - ); - assert.equal(outcome.ok, false); - if (!outcome.ok) { - assert.equal(outcome.reason, 'incognito_active'); - assert.match( - outcome.message, - privacyPayload && !Array.isArray(privacyPayload) && - typeof privacyPayload === 'object' && - (privacyPayload as { incognitoActive?: unknown }).incognitoActive === true - ? /incognito is active/ - : /could not be verified/, - ); - } - assert.deepEqual({ listCalls, readCalls }, { listCalls: 0, readCalls: 0 }); - } - }); -}); - -describe('thread search text projection', () => { - it('normalizes case and NFC and caps snippets by code point', () => { - assert.equal(foldForMatch('HELLO'), 'hello'); - assert.equal(foldForMatch('Héllo'), foldForMatch('Héllo')); - assert.equal(findMatch('hello world', foldForMatch('WORLD')), 6); - - const capped = capCodePoints(`${'a'.repeat(500)}🦊`, SNIPPET_MAX_CODE_POINTS); - assert.equal(Array.from(capped).length, SNIPPET_MAX_CODE_POINTS); - assert.ok(capped.endsWith('…')); - }); - - it('bounds and classifies serialized tool results', async () => { - assert.equal(collectSearchableText(toolResult({ result: 'short' })), '{"result":"short"}'); - const extracted = collectSearchableText(toolResult({ data: 'X'.repeat(100_000) })); - assert.ok(extracted); - assert.ok(Buffer.byteLength(extracted, 'utf8') <= TOOL_RESULT_SCAN_CAP_BYTES); - - const hits = expectResults( - await runThreadSearch( - { source: 'thread', query: 'short', limit: 5 }, - makeDeps({ - s1: { session: session({ id: 's1' }), messages: [toolResult({ result: 'short' })] }, - }), - ), - ); - assert.equal(hits[0]?.target?.matchKind, 'tool_result'); - assert.equal(hits[0]?.target?.messageId, 'tr1'); - }); - - it('indexes tool intent but not tool names or display names', async () => { - assert.equal(collectSearchableText(toolCall('check disk usage')), 'check disk usage'); - assert.equal(collectSearchableText(toolCall()), undefined); - - const entries = { - s1: { session: session({ id: 's1' }), messages: [toolCall('check disk usage on /var')] }, - }; - for (const query of ['Bash', 'Shell command']) { - assert.deepEqual( - expectResults( - await runThreadSearch({ source: 'thread', query, limit: 5 }, makeDeps(entries)), - ), - [], - ); - } - const hits = expectResults( - await runThreadSearch( - { source: 'thread', query: 'disk usage', limit: 5 }, - makeDeps(entries), - ), - ); - assert.equal(hits.length, 1); - assert.equal(hits[0]?.target?.matchKind, 'tool_intent'); - assert.equal(hits[0]?.target?.messageId, 'tc1'); - }); - - it('indexes assistant answers without exposing thinking', async () => { - const message = assistantMessage( - 'this is the visible answer', - 'private reasoning path: greeting me in Chinese', - ); - assert.equal(collectSearchableText(message), 'this is the visible answer'); - - const entries = { s1: { session: session({ id: 's1' }), messages: [message] } }; - assert.equal( - expectResults( - await runThreadSearch( - { source: 'thread', query: 'private reasoning', limit: 5 }, - makeDeps(entries), - ), - ).length, - 0, - ); - const visible = expectResults( - await runThreadSearch( - { source: 'thread', query: 'visible answer', limit: 5 }, - makeDeps(entries), - ), - ); - assert.equal(visible.length, 1); - assert.equal(visible[0]?.target?.matchKind, 'assistant_message'); - assert.equal(visible[0]?.target?.messageId, 'a1'); - assert.equal(visible[0]?.snippet?.includes('private reasoning'), false); - }); - - it('excludes system, token, turn-state, and permission records', () => { - const excluded: StoredMessage[] = [ - { - type: 'system_note', - id: 'sn1', - ts: 1, - kind: 'session_start', - data: { note: 'private' }, - }, - { type: 'token_usage', id: 'tk1', turnId: 't1', ts: 1, input: 1, output: 2 }, - { - type: 'turn_state', - id: 'ts1', - turnId: 't1', - ts: 1, - status: 'completed', - }, - { - type: 'permission_decision', - id: 'pd1', - turnId: 't1', - ts: 1, - toolUseId: 'call1', - toolName: 'Bash', - decision: 'allow', - }, - ]; - for (const message of excluded) assert.equal(collectSearchableText(message), undefined); - }); - -}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 958ffbc26a..5000252365 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -254,7 +254,7 @@ import { MAKA_CLIENT_PLUGIN_SCHEME, registerClientPluginIpc, } from './client-plugin-transport.js'; -import { registerRuntimeHostSearchIpc } from "./runtime-host-search-ipc-main.js"; +import { registerRuntimeHostRecallIpc } from "./runtime-host-recall-ipc-main.js"; import { createRuntimeHostProjectCatalog } from "./runtime-host-project-catalog.js"; import { createRuntimeHostDefaultRecovery } from "./runtime-host-default-recovery.js"; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; @@ -1796,7 +1796,7 @@ function registerHostClientIpc( openPath: (path) => shell.openPath(path), allowLocalPaths: !usesHostWorkspace, }); - registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client }); + registerRuntimeHostRecallIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, client, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index dbda4c14b8..dbf0ce9dff 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -719,6 +719,17 @@ export class DesktopRuntimeHostClient { } } + /** + * Recall over this Host's own corpus. + * + * Recall runs inside the Host — the Session manager, fact store, and + * material fetch are all Host-owned — so this is a request, not a scan. + * Desktop issues one per Host and merges; it never reads the transcripts. + */ + queryRecall(input: OperationInput<'recall.query'>): Promise> { + return this.request('recall.query', input); + } + async listSessions(): Promise { this.#assertOpen(); try { diff --git a/apps/desktop/src/main/runtime-host-recall-ipc-main.ts b/apps/desktop/src/main/runtime-host-recall-ipc-main.ts new file mode 100644 index 0000000000..72e5fafd10 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-recall-ipc-main.ts @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { WebContents } from 'electron'; +import { readWithFallback, type ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; + +/** + * `search:recall` — one Host's answer to a recall query. + * + * The scan happens inside the Host (see `recall.query`), so this handler does + * no retrieval of its own: it forwards the request, bounds the wait, and relays + * the envelope. Fan-out across Hosts and merging belong to the renderer's + * search client, which is the only layer that knows how many Hosts there are. + * + * Cancellation is best-effort. The Host owns the scan; dropping our side of an + * abandoned request stops us waiting on it, and the pending map is per-renderer + * so a replacement window cannot inherit a stale entry. + */ +interface RuntimeHostRecallIpcDeps { + readonly ipcMain: ReconnectableReadIpcMain; + readonly client: Pick; +} + +export function registerRuntimeHostRecallIpc(deps: RuntimeHostRecallIpcDeps): void { + const pending = new WeakMap>(); + + deps.ipcMain.handle( + 'search:recall', + async (event, request: unknown, requestId?: unknown): Promise => { + if ( + requestId !== undefined && + (typeof requestId !== 'string' || !requestId || requestId.length > 128) + ) { + return { ok: false, reason: 'invalid_query', message: 'Invalid search request identity.' }; + } + const controller = new AbortController(); + const release = () => { + event.sender?.removeListener('destroyed', abort); + event.sender?.removeListener('render-process-gone', abort); + if (typeof requestId === 'string') { + const requests = pending.get(event.sender); + if (requests?.get(requestId) === controller) requests.delete(requestId); + } + }; + const abort = () => controller.abort(); + if (typeof requestId === 'string') { + let requests = pending.get(event.sender); + if (!requests) { + requests = new Map(); + pending.set(event.sender, requests); + } + requests.get(requestId)?.abort(); + requests.set(requestId, controller); + } + controller.signal.addEventListener('abort', release, { once: true }); + event.sender?.once('destroyed', abort); + // Crash recovery reloads the same WebContents without destroying it. + event.sender?.once('render-process-gone', abort); + // Cancellation must end this call, not merely mark a boolean. The Host + // owns the scan and may take as long as it likes to answer; without + // racing it, a cancelled search would hold the IPC channel open until + // the Host replied to a question nobody is waiting for. + const cancelled = new Promise<{ ok: false; reason: string; message: string }>((resolve) => { + const settle = () => + resolve({ ok: false, reason: 'aborted', message: 'History search was aborted.' }); + if (controller.signal.aborted) settle(); + else controller.signal.addEventListener('abort', settle, { once: true }); + }); + try { + // The payload crossed an IPC boundary, so it is untrusted in shape; + // the Host decodes it and answers with a typed refusal rather than + // trusting anything the renderer sent. + // + // The read keeps its own failure semantics: `readWithFallback` answers + // `null` for an ordinary Host failure and rethrows a failure the + // reconnect policy owns. Racing it against cancellation must not + // hide that, so the rejection is re-raised after the race. + const read = readWithFallback( + () => deps.client.queryRecall(request as never), + null, + ); + // A rejection leaving the race unobserved would surface as an + // unhandled rejection; attach a no-op handler purely to mark it seen. + read.catch(() => undefined); + const result = await Promise.race([read, cancelled]); + if (controller.signal.aborted) { + return { ok: false, reason: 'aborted', message: 'History search was aborted.' }; + } + if (result === null) { + return { + ok: false, + reason: 'provider_error', + message: 'Runtime Host is unavailable for search', + }; + } + return result; + } finally { + controller.signal.removeEventListener('abort', release); + release(); + } + }, + ); + + // Register after search so requests waiting for a candidate start before + // their queued cancellations are delivered. + deps.ipcMain.handle('search:recall:cancel', (event, requestId: unknown) => { + if (typeof requestId === 'string') pending.get(event.sender)?.get(requestId)?.abort(); + }); +} diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts deleted file mode 100644 index 564e27e2a5..0000000000 --- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { SearchResult } from '@maka/core/search'; -import { runThreadSearch } from '@maka/core/thread-search'; -import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; -import type { WebContents } from 'electron'; -import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js'; -import { - readWithFallback, - type ReconnectableReadIpcMain, -} from './ipc-reconnect-policy.js'; - -interface RuntimeHostSearchIpcDeps { - readonly ipcMain: ReconnectableReadIpcMain; - readonly client: Pick< - DesktopRuntimeHostClient, - 'listSessions' | 'openSession' | 'queryRuntimePolicy' - >; -} - -export function registerRuntimeHostSearchIpc( - deps: RuntimeHostSearchIpcDeps, -): void { - const pending = new WeakMap>(); - // A search belongs to this candidate and its cancellation registry. Replaying - // it on a replacement would revive work the renderer has already abandoned. - deps.ipcMain.handle('search:thread', async (event, request: unknown, requestId?: unknown) => { - if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) { - return { ok: false, reason: 'invalid_query', message: 'Invalid search request identity.' }; - } - const controller = new AbortController(); - const release = () => { - event.sender?.removeListener('destroyed', abort); - event.sender?.removeListener('render-process-gone', abort); - if (typeof requestId === 'string') { - const requests = pending.get(event.sender); - if (requests?.get(requestId) === controller) requests.delete(requestId); - } - }; - const abort = () => controller.abort(); - if (typeof requestId === 'string') { - let requests = pending.get(event.sender); - if (!requests) { - requests = new Map(); - pending.set(event.sender, requests); - } - requests.get(requestId)?.abort(); - requests.set(requestId, controller); - } - controller.signal.addEventListener('abort', release, { once: true }); - event.sender?.once('destroyed', abort); - // Crash recovery reloads the same WebContents without destroying it. - event.sender?.once('render-process-gone', abort); - try { - const result = await runThreadSearch(request, { - listSessions: async () => - (await deps.client.listSessions()).map(toDesktopHostSessionSummary), - readMessages: (sessionId, signal) => - readWithFallback(async () => { - if (signal?.aborted) return null; - const session = await deps.client.openSession(sessionId); - // This handle belongs only to this search. Closing it immediately - // stops the Host client's paginated transcript reader before its - // next page, including when a read is currently awaiting a reply. - let closeTask: Promise | undefined; - const close = () => (closeTask ??= session.close()); - const cancelRead = () => { void close().catch(() => undefined); }; - signal?.addEventListener('abort', cancelRead, { once: true }); - try { - if (signal?.aborted) return null; - return await session.loadTranscript(); - } finally { - signal?.removeEventListener('abort', cancelRead); - await close(); - } - }, null), - getPrivacyContext: async () => ({ - incognitoActive: (await deps.client.queryRuntimePolicy()).policy.privacy - .incognitoActive, - }), - }, { abortSignal: controller.signal }); - return result.ok ? result.results.map(projectDesktopSearchResult) : result; - } catch (error) { - if (!controller.signal.aborted) throw error; - return { ok: false, reason: 'aborted', message: 'History search was aborted.' }; - } finally { - controller.signal.removeEventListener('abort', release); - release(); - } - }); - // Register after search so requests waiting for a candidate start before - // their queued cancellations are delivered. - deps.ipcMain.handle('search:thread:cancel', (event, requestId: unknown) => { - if (typeof requestId === 'string') pending.get(event.sender)?.get(requestId)?.abort(); - }); -} - -function projectDesktopSearchResult(result: SearchResult): SearchResult { - if (!result.target) return result; - return { - ...result, - target: { - kind: result.target.kind, - sessionId: result.target.sessionId, - ...(result.target.turnId !== undefined ? { turnId: result.target.turnId } : {}), - ...(result.target.sequence !== undefined ? { sequence: result.target.sequence } : {}), - }, - }; -} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 2cddb6b9b8..e75c729312 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -74,7 +74,6 @@ import type { ReviseBeforeTurnInput, } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; -import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; import type { SessionChangedEvent, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { SessionSnapshot } from '@maka/core/session-reference'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -271,6 +270,48 @@ import type { RuntimeHostProfile, RuntimeHostProfileAccess, } from '@maka/runtime-host/client'; +/** + * A recall query as the Search modal issues it, and the envelope it accepts. + * + * Defined here rather than imported from the preload implementation so the + * bridge contract does not depend on that module: the renderer architecture + * check prices every preload file this contract reaches, and a new entry in + * that debt ledger is forbidden. The implementation imports these instead. + */ +export interface RecallSearchRequest { + readonly terms: readonly string[]; + readonly limit?: number; + readonly sessionId?: string; + readonly since?: number; + readonly until?: number; +} + +export interface RecallSearchPassage { + readonly sessionId: string; + readonly sessionTitle: string; + readonly turnId?: string; + readonly anchorMessageId: string; + /** The anchor's index in its Session transcript; what navigation scrolls to. */ + readonly sequence: number; + readonly messages: readonly { + readonly messageId: string; + readonly role: 'user' | 'assistant' | 'tool'; + readonly matchKind: string; + readonly text: string; + readonly timestamp: number; + readonly isAnchor: boolean; + }[]; + readonly matchedTerms: readonly string[]; + readonly score: number; + readonly lastMessageAt?: number; +} + +export interface RecallSearchResult { + readonly passages: readonly RecallSearchPassage[]; + readonly gaps: string; + readonly searchedEverySession: boolean; +} + export interface OnboardingSnapshot { state: OnboardingState; milestones: OnboardingMilestone[]; @@ -1656,14 +1697,11 @@ export interface MakaBridge { readBytes(sessionId: string, artifactId: string): Promise; }; search: { - thread( - request: SearchRequest, + recall( + request: RecallSearchRequest, requestId?: string, - ): Promise< - | SearchResult[] - | { ok: false; reason: SearchErrorReason; message: string } - >; - cancelThread(requestId: string): Promise; + ): Promise; + cancelRecall(requestId: string): Promise; }; openAiCodex: { getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; diff --git a/apps/desktop/src/preload/multi-host-recall-search.ts b/apps/desktop/src/preload/multi-host-recall-search.ts new file mode 100644 index 0000000000..17ddb79900 --- /dev/null +++ b/apps/desktop/src/preload/multi-host-recall-search.ts @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Multi-Host recall fan-out. + * + * Each Host recalls over its own corpus — its Session store, its fact store, + * its privacy state — and this layer only merges the answers. That split is + * why scoring stays inside the Host: BM25's idf is a property of one corpus, + * so a score from Host A is not comparable to one from Host B and the merge + * must not pretend otherwise. + * + * Merge rule: interleave. Taking each Host's best, then each Host's second, + * and so on keeps a Host with a large corpus from filling the whole list, and + * it is the one order that does not require comparing scores that are not + * comparable. This is the same rule the thread-search fan-out used, kept + * deliberately so the visible ordering does not shift for a single-Host user. + * + * A Host that fails or is unreachable contributes nothing rather than failing + * the search: one machine being down must not hide the history on the others. + */ + +// The envelope types live in the bridge contract, which the renderer already +// reaches: importing them from here would add this module to the preload +// closure the renderer architecture check prices, and that ledger only shrinks. +// Re-exported so callers and tests can keep importing them from one place. +import type { + RecallSearchPassage, + RecallSearchRequest, + RecallSearchResult, +} from './bridge-contract.js'; + +export type { RecallSearchPassage, RecallSearchRequest, RecallSearchResult }; + +export interface RecallSearchError { + readonly ok: false; + readonly reason: string; + readonly message: string; +} + +interface RecallSearchHostResponse { + readonly ok: true; + readonly result: { + readonly ok: true; + readonly passages: readonly RecallSearchPassage[]; + readonly gaps: string; + readonly searchedEverySession: boolean; + }; +} + +export function createRecallSearchClient(input: { + scopes(): Promise; + search(scope: Scope, request: RecallSearchRequest, requestId: string): Promise; + cancel(scope: Scope, requestId: string): Promise; +}) { + const pending = new Map }>(); + return { + async recall( + request: RecallSearchRequest, + requestId: string = crypto.randomUUID(), + ): Promise { + if (pending.has(requestId)) throw new Error('Search request is already active'); + let scopes: readonly Scope[] = []; + let cancelled = false; + let finishCancel!: (error: RecallSearchError) => void; + const cancellation = new Promise((resolve) => { + finishCancel = resolve; + }); + pending.set(requestId, { + async cancel() { + cancelled = true; + finishCancel({ ok: false, reason: 'aborted', message: 'History search was aborted.' }); + await Promise.all(scopes.map((scope) => input.cancel(scope, requestId))); + }, + }); + try { + return await Promise.race([ + (async () => { + scopes = await input.scopes(); + if (cancelled) return cancellation; + return collectRecallResponses( + await Promise.allSettled( + scopes.map((scope) => input.search(scope, request, requestId)), + ), + request.limit ?? 10, + ); + })(), + cancellation, + ]); + } finally { + pending.delete(requestId); + } + }, + async cancelRecall(requestId: string): Promise { + await pending.get(requestId)?.cancel(); + }, + }; +} + +/** + * Interleave the Hosts' passages and stop at `limit`. + * + * The `gaps` string of the winning Host is reported only when there is one + * Host; across several it would describe a corpus the user did not ask about + * separately. `searchedEverySession` is false if any Host declined a full + * scan, because the envelope as a whole then did not cover every Session. + */ +export function collectRecallResponses( + settled: readonly PromiseSettledResult[], + limit: number, +): RecallSearchResult | RecallSearchError { + const hosts: RecallSearchHostResponse['result'][] = []; + const failures: RecallSearchError[] = []; + for (const outcome of settled) { + if (outcome.status !== 'fulfilled') continue; + if (isSuccessfulHost(outcome.value)) { + hosts.push(outcome.value.result); + continue; + } + const failure = failureOf(outcome.value); + if (failure) failures.push(failure); + } + if (hosts.length === 0) { + return ( + failures[0] ?? { + ok: false, + reason: 'provider_error', + message: 'No Runtime Host is available for search', + } + ); + } + const passages: RecallSearchPassage[] = []; + for (let index = 0; passages.length < limit; index += 1) { + let appended = false; + for (const host of hosts) { + const passage = host.passages[index]; + if (!passage) continue; + passages.push(passage); + appended = true; + if (passages.length === limit) break; + } + if (!appended) break; + } + const only = hosts.length === 1 ? hosts[0]! : undefined; + return { + passages, + gaps: only?.gaps ?? '', + searchedEverySession: only + ? only.searchedEverySession + : hosts.every((host) => host.searchedEverySession), + }; +} + +function isSuccessfulHost(value: unknown): value is RecallSearchHostResponse { + return ( + typeof value === 'object' && + value !== null && + (value as { ok?: unknown }).ok === true && + typeof (value as { result?: unknown }).result === 'object' && + (value as { result: { ok?: unknown } }).result !== null && + (value as { result: { ok?: unknown } }).result.ok === true && + Array.isArray((value as { result: { passages?: unknown } }).result.passages) + ); +} + +function failureOf(value: unknown): RecallSearchError | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const record = value as { ok?: unknown; reason?: unknown; message?: unknown }; + if (record.ok !== false) return undefined; + if (typeof record.reason !== 'string') return undefined; + return { + ok: false, + reason: record.reason, + message: typeof record.message === 'string' ? record.message : 'Search failed.', + }; +} diff --git a/apps/desktop/src/preload/multi-host-thread-search.ts b/apps/desktop/src/preload/multi-host-thread-search.ts deleted file mode 100644 index 611b1bbca0..0000000000 --- a/apps/desktop/src/preload/multi-host-thread-search.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { SearchError, SearchRequest, SearchResult } from '@maka/core/search'; - -/** Owns a request across Host discovery, fan-out, and cancellation. */ -export function createThreadSearchClient(input: { - scopes(): Promise; - search(scope: Scope, request: SearchRequest, requestId: string): Promise; - cancel(scope: Scope, requestId: string): Promise; -}) { - const pending = new Map }>(); - return { - async thread(request: SearchRequest, requestId: string = crypto.randomUUID()): Promise { - if (pending.has(requestId)) throw new Error('Search request is already active'); - let scopes: readonly Scope[] = []; - let cancelled = false; - let finishCancel!: (error: SearchError) => void; - const cancellation = new Promise((resolve) => { finishCancel = resolve; }); - pending.set(requestId, { - async cancel() { - cancelled = true; - finishCancel({ ok: false, reason: 'aborted', message: 'History search was aborted.' }); - await Promise.all(scopes.map((scope) => input.cancel(scope, requestId))); - }, - }); - try { - return await Promise.race([ - (async () => { - scopes = await input.scopes(); - if (cancelled) return cancellation; - return collectThreadSearchResponses( - scopes.map((scope) => input.search(scope, request, requestId)), - request.limit, - ); - })(), - cancellation, - ]); - } finally { - pending.delete(requestId); - } - }, - async cancelThread(requestId: string): Promise { - await pending.get(requestId)?.cancel(); - }, - }; -} - -export async function collectThreadSearchResponses( - requests: readonly Promise[], - limit: number, -): Promise { - if (requests.length === 0) { - return { - ok: false, - reason: 'provider_error', - message: 'No Runtime Host is available for search', - }; - } - - const settled = await Promise.allSettled(requests); - const responses = settled.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [], - ); - if (responses.length === 0) { - throw (settled[0] as PromiseRejectedResult).reason; - } - - const matches = responses.filter((response): response is SearchResult[] => - Array.isArray(response), - ); - const results: SearchResult[] = []; - for (let index = 0; results.length < limit; index += 1) { - let appended = false; - for (const hostMatches of matches) { - const match = hostMatches[index]; - if (!match) continue; - results.push(match); - appended = true; - if (results.length === limit) break; - } - if (!appended) break; - } - return results.length > 0 - ? results - : responses.find((response) => !Array.isArray(response)) ?? []; -} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fc42c7d750..919890f26c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -38,7 +38,7 @@ import { } from '@maka/runtime-host/profile-kind'; import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import { encodeIngestItems } from './attachment-ingest-payload.js'; -import { createThreadSearchClient } from './multi-host-thread-search.js'; +import { createRecallSearchClient } from './multi-host-recall-search.js'; import { releaseSessionObservation } from './session-observation-release.js'; import { resolveDesktopWorkHubCoordinationCreateScope, @@ -172,7 +172,6 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { TurnOrchestration, SessionListFilter } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; -import type { SearchErrorReason, SearchResult } from '@maka/core/search'; import type { SessionCatalogSummary, SessionChangedEvent, @@ -3223,28 +3222,36 @@ const makaBridge = { return invokeSessionRuntimeHost('attachments:readBytes', sessionId, artifactId); }, }, - search: createThreadSearchClient({ + search: createRecallSearchClient({ // Search each ready Owner Host independently; Guests cannot search a workspace. scopes: readyOwnerRuntimeHostScopes, async search(scope, request, requestId) { - const result = await ipcRenderer.invoke('search:thread', scope, request, requestId) as - | SearchResult[] - | { ok: false; reason: SearchErrorReason; message: string }; - return Array.isArray(result) - ? result.map((entry) => - entry.target?.kind === 'thread' + const result = await ipcRenderer.invoke('search:recall', scope, request, requestId); + if (typeof result !== 'object' || result === null) return result; + const envelope = result as { ok?: unknown; result?: { ok?: unknown; passages?: unknown } }; + if (envelope.ok !== true || envelope.result?.ok !== true) return result; + if (!Array.isArray(envelope.result.passages)) return result; + // A passage's sessionId is meaningful only inside its own Host, so it is + // qualified here, exactly as the previous scan lane did for its results. + return { + ok: true, + result: { + ...envelope.result, + passages: envelope.result.passages.map((passage) => + typeof passage === 'object' && passage !== null ? { - ...entry, - target: { - ...entry.target, - sessionId: recordRuntimeHostSessionScope(scope, entry.target.sessionId), - }, + ...passage, + sessionId: recordRuntimeHostSessionScope( + scope, + (passage as { sessionId: string }).sessionId, + ), } - : entry, - ) - : result; + : passage, + ), + }, + }; }, - cancel: (scope, requestId) => ipcRenderer.invoke('search:thread:cancel', scope, requestId), + cancel: (scope, requestId) => ipcRenderer.invoke('search:recall:cancel', scope, requestId), }), // Browser-assisted Codex account bridge. NEVER returns raw OAuth // credentials; the renderer only sees account state and action results. diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index e2cd378682..76d131e376 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -139,7 +139,7 @@ export function buildCommandList(args: { /** * PR-CMD-PALETTE-ENRICH-0: jump to an app module (会话 / 计划 / * 技能 / 每日回顾) directly from the palette. Search itself is - * already covered by the existing thread-search hookup, so the + * already covered by the existing recall-search hookup, so the * `search` module nav id is intentionally omitted here. */ onSelectModule?(selection: NavSelection): void; diff --git a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts index 5f8538eea9..51154fae5b 100644 --- a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts +++ b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts @@ -87,8 +87,8 @@ export function useOverlaysController(): OverlaysController { closePalette: () => setPaletteOpen(false), openSearch: () => setSearchOpen(true), closeSearch: () => setSearchOpen(false), - searchThread: (request, requestId) => services.search.thread(request, requestId), - cancelSearchThread: (requestId) => services.search.cancelThread(requestId), + searchRecall: (request, requestId) => services.search.recall(request, requestId), + cancelSearchRecall: (requestId) => services.search.cancelRecall(requestId), setSearchScrollTarget, openSettings: () => openSettingsWith({ kind: 'settings' }), openSettingsSection: (section) => openSettingsWith({ kind: 'section', section }), diff --git a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts index 1756b65d6a..4961b11bf7 100644 --- a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts +++ b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts @@ -19,7 +19,7 @@ import type { ProviderType } from '@maka/core/llm-connections'; import type { SettingsSection } from '@maka/core/settings'; -import type { OverlaySearchThread } from '../ports.js'; +import type { OverlaySearchRecall } from '../ports.js'; import type { SearchScrollTarget } from './search-scroll-target.js'; import type { SettingsModalState } from './settings-modal-state.js'; @@ -31,8 +31,8 @@ export interface OverlaysCommands { closePalette(): void; openSearch(): void; closeSearch(): void; - searchThread: OverlaySearchThread; - cancelSearchThread(requestId: string): Promise; + searchRecall: OverlaySearchRecall; + cancelSearchRecall(requestId: string): Promise; setSearchScrollTarget(target: SearchScrollTarget | null): void; openSettings(): void; openSettingsSection(section: SettingsSection): void; diff --git a/apps/desktop/src/renderer/features/overlays/ports.ts b/apps/desktop/src/renderer/features/overlays/ports.ts index c52331eb2f..c9cfa445c5 100644 --- a/apps/desktop/src/renderer/features/overlays/ports.ts +++ b/apps/desktop/src/renderer/features/overlays/ports.ts @@ -20,15 +20,15 @@ import type { SettingsSection } from '@maka/core/settings'; import type { SearchModal } from '@maka/ui'; -/** The thread search the Search modal runs; the type is the modal's own. */ -export type OverlaySearchThread = NonNullable< +/** The recall search the Search modal runs; the type is the modal's own. */ +export type OverlaySearchRecall = NonNullable< Parameters[0]['deps'] ->['searchThread']; +>['searchRecall']; /** The minimum environment capabilities the overlays need. */ export interface OverlaySearchService { - thread: OverlaySearchThread; - cancelThread(requestId: string): Promise; + recall: OverlaySearchRecall; + cancelRecall(requestId: string): Promise; } export interface OverlaySettingsSectionStore { diff --git a/apps/desktop/src/renderer/features/overlays/testing.ts b/apps/desktop/src/renderer/features/overlays/testing.ts index 0560faf339..e3928abfcd 100644 --- a/apps/desktop/src/renderer/features/overlays/testing.ts +++ b/apps/desktop/src/renderer/features/overlays/testing.ts @@ -37,7 +37,10 @@ export function createFakeOverlaysServices( overrides: Partial = {}, ): OverlaysServices { return { - search: { thread: async () => [], cancelThread: async () => undefined }, + search: { + recall: async () => ({ passages: [], gaps: '', searchedEverySession: true }), + cancelRecall: async () => undefined, + }, settingsSection: { persist: () => undefined }, focus: { blurActiveElement: () => undefined }, ...overrides, diff --git a/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx b/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx index 2ef4af9a74..7e184b4e8a 100644 --- a/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx +++ b/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx @@ -27,7 +27,7 @@ import { useOverlays } from './overlays-context.js'; * shell action. Astryx restores the opener for ordinary closes. * * `deps` keeps one identity for the life of the controller. The modal's - * debounce effect lists `searchThread` in its dependencies, and a fresh + * debounce effect lists `searchRecall` in its dependencies, and a fresh * identity per render tore the timer down before it fired while a turn was * streaming, which made search dead exactly then. */ @@ -36,8 +36,8 @@ export function SearchModalHost(props: { }) { const { commands, selectors } = useOverlays(); const deps = useMemo(() => ({ - searchThread: commands.searchThread, - cancelThread: commands.cancelSearchThread, + searchRecall: commands.searchRecall, + cancelRecall: commands.cancelSearchRecall, }), [commands]); return ( ; -type SearchResponse = SearchResult[] | { ok: false; reason: SearchErrorReason; message: string }; +type SearchResponse = RecallSearchOutcome | { ok: false; reason: string; message: string }; type SearchModalDeps = NonNullable[0]['deps']>; const noop = () => undefined; const noopNavigate = (_sessionId: string, _turnId?: string) => undefined; -const threadResults: SearchResult[] = [ - { - source: 'thread', - title: 'Benchmark 结果横评', - summary: '任务 · 今天 10:24', - snippet: '把 benchmark 输出整理成稳定的对比表,再补一轮 verifier。', - target: { kind: 'thread', sessionId: 'session-benchmark', turnId: 'turn-benchmark-table' }, - truncated: true, - }, - { - source: 'thread', - title: 'Command palette 搜索状态', - summary: '任务 · 昨天 18:42', - snippet: 'content search blocked state 要保持 disabled,不能触发关闭。', - target: { kind: 'thread', sessionId: 'session-command-search' }, - }, - { - source: 'thread', - title: 'Harbor adapter metadata', - summary: '任务 · 周一', - snippet: '确认 provider env passthrough,不要复制本地 adapter。', - target: { kind: 'thread', sessionId: 'session-harbor', turnId: 'turn-provider-env' }, - }, +function passage( + sessionId: string, + sessionTitle: string, + anchorText: string, + matchKind: string, + turnId?: string, +): RecallSearchPassage { + return { + sessionId, + sessionTitle, + ...(turnId ? { turnId } : {}), + anchorMessageId: `${sessionId}-anchor`, + sequence: 4, + messages: [ + { + messageId: `${sessionId}-anchor`, + role: 'assistant', + matchKind, + text: anchorText, + timestamp: 1_700_000_000_000, + isAnchor: true, + }, + ], + matchedTerms: [], + score: 1, + }; +} + +const recallPassages: RecallSearchPassage[] = [ + passage( + 'session-benchmark', + 'Benchmark 结果横评', + '把 benchmark 输出整理成稳定的对比表,再补一轮 verifier。', + 'assistant_message', + 'turn-benchmark-table', + ), + passage( + 'session-command-search', + 'Command palette 搜索状态', + 'content search blocked state 要保持 disabled,不能触发关闭。', + 'user_message', + ), + passage( + 'session-harbor', + 'Harbor adapter metadata', + '确认 provider env passthrough,不要复制本地 adapter。', + 'tool_intent', + 'turn-provider-env', + ), ]; const paletteCommands: Command[] = [ @@ -146,7 +175,10 @@ const paletteCommands: Command[] = [ function searchModalDeps(response: SearchResponse): SearchModalDeps { return { - searchThread: async () => response, + searchRecall: async () => + Array.isArray((response as RecallSearchOutcome).passages) + ? (response as RecallSearchOutcome) + : (response as { ok: false; reason: string; message: string }), }; } @@ -238,11 +270,17 @@ export const CommandPaletteGroupedResults: Story = { ), }; +const recallOutcome: RecallSearchOutcome = { + passages: recallPassages, + gaps: 'Searched 3 Session(s).', + searchedEverySession: true, +}; + // Real path: same modal with matches, grouped by session with the matched excerpt. export const SearchModalResults: Story = { render: () => ( ), play: async ({ canvasElement }) => { diff --git a/packages/core/package.json b/packages/core/package.json index 1a042c4b2e..6535ad6ae5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -129,7 +129,7 @@ "./session-send-projection": "./dist/session-send-projection.js", "./session-name": "./dist/session-name.js", "./recall": "./dist/recall.js", - "./thread-search": "./dist/thread-search.js", + "./transcript-search": "./dist/transcript-search.js", "./agent-graph-timeline": "./dist/agent-graph-timeline.js", "./agent-swarm": "./dist/agent-swarm.js", "./bot-chat-settings": "./dist/bot-chat-settings.js", diff --git a/packages/core/src/__tests__/recall.test.ts b/packages/core/src/__tests__/recall.test.ts index 0ed41b6a83..f5456169eb 100644 --- a/packages/core/src/__tests__/recall.test.ts +++ b/packages/core/src/__tests__/recall.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { SessionSummary, StoredMessage } from '../session.js'; -import { foldForMatch } from '../thread-search.js'; +import { foldForMatch } from '../transcript-search.js'; import { expandRecallPassage, fetchRecallMaterial, @@ -746,6 +746,31 @@ test('expansion widens a passage around an anchor recall reported', async () => assert.ok(expanded.ok); assert.ok(expanded.passage.messages.length >= passage.messages.length); assert.equal(expanded.passage.anchorMessageId, passage.anchorMessageId); + // Expansion rebuilds the passage from the same transcript, so the + // navigation coordinate has to survive the round trip unchanged: a UI that + // recalled a passage and then widened it must land on the same message. + assert.equal(expanded.passage.sequence, passage.sequence); +}); + +test('a passage reports the anchor index within its own transcript', async () => { + const data = mixedCorpus(); + // `m6` is the ninth entry of `s-pet` in insertion order, which is the order + // `readMessages` returns and therefore the sequence the transcript reader + // addresses. Matching a term unique to it pins the anchor without relying on + // ranking. + const result = await runRecall({ terms: ['再查一下浮窗的显示条件'], limit: 1 }, scanDeps(data)); + assert.ok(result.ok); + const passage = result.passages[0]; + assert.ok(passage, 'a term unique to one message must produce a passage'); + assert.equal(passage.sessionId, 's-pet'); + assert.equal(passage.anchorMessageId, 'm6'); + const transcript = data.messages.get('s-pet') ?? []; + const index = transcript.findIndex((message) => message.id === passage.anchorMessageId); + assert.equal( + passage.sequence, + index, + 'sequence must be the anchor index in the transcript recall read, not a message count', + ); }); test('expansion refuses an anchor inside the active turn', async () => { diff --git a/packages/core/src/recall.ts b/packages/core/src/recall.ts index d26007442b..d8ef58ad35 100644 --- a/packages/core/src/recall.ts +++ b/packages/core/src/recall.ts @@ -62,7 +62,7 @@ import { redactSecrets } from './redaction.js'; import { SEARCH_QUERY_MAX_CHARS } from './search.js'; import { collapseSessionRevisions } from './session-revisions.js'; import type { SessionSummary, StoredMessage } from './session.js'; -import { foldForMatch, MAX_SESSIONS_SCANNED, threadSearchMatchKind } from './thread-search.js'; +import { foldForMatch, MAX_SESSIONS_SCANNED, threadSearchMatchKind } from './transcript-search.js'; /** Okapi BM25 term-frequency saturation, Lucene's default. */ export const RECALL_BM25_K1 = 1.2; @@ -360,6 +360,21 @@ export interface RecallPassage { readonly sessionTitle: string; readonly turnId?: string; readonly anchorMessageId: string; + /** + * Zero-based index of the anchor message within its Session transcript, the + * same coordinate a transcript reader scrolls by. + * + * A UI that navigates into a Session needs a position, not just an identity: + * the transcript reader scrolls by sequence, and a message id alone would + * force it to page the whole transcript to find the anchor. Recall already + * has the index — it locates the anchor by `findIndex` — so carrying it costs + * nothing and spares every consumer a second lookup. + * + * Present only when the anchor was located in the transcript it was + * assembled from; `buildPassage` returns undefined in the other case, so + * this is always defined on a passage that exists. + */ + readonly sequence: number; readonly messages: readonly RecallPassageMessage[]; readonly matchedTerms: readonly string[]; readonly score: number; @@ -1318,6 +1333,10 @@ function buildPassage( sessionTitle: redactSecrets(session?.name ?? ''), ...(anchor.turnId ? { turnId: anchor.turnId } : {}), anchorMessageId: anchor.message.id, + // The anchor's position in the transcript this passage was built from. + // `anchorIndex` is already `findIndex` over that exact array, so this is + // the same coordinate the transcript reader addresses. + sequence: anchorIndex, messages, matchedTerms: anchor.matchedTerms, score: Number(anchor.score.toFixed(4)), diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts deleted file mode 100644 index 7e6086e8d5..0000000000 --- a/packages/core/src/thread-search.ts +++ /dev/null @@ -1,603 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Local thread / session search — bounded scan, no FTS5. - * - * Anchors: - * - Current behavior is pinned by the focused thread-search tests. - * - Contract: `@maka/core/search` (PR-SEARCH-0 + PR-SEARCH-1.5 `SearchResultTarget`). - * - Implementation lane greenlight: xuan msg `074714c7`. - * - * Scope (this module, PR-SEARCH-2): - * - Pure helper. Accepts an injected `ThreadSearchDeps` so unit tests can - * supply fake `listSessions` / `readMessages` without an Electron runtime. - * - Bounded substring scan over user-visible message types only: - * UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage. - * Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage / - * PermissionDecisionMessage. - * - Excludes sessions with `backend === 'fake'` (retired local simulation, - * plus the e2e fixtures that still seed it). Desktop also excludes archived - * sessions; Agent global history opts in to them explicitly. - * - Snippets are redacted via `@maka/core/redaction.redactSecrets()`. - * - `ToolResultMessage.content` is JSON-serialized for scan and capped to - * the first `TOOL_RESULT_SCAN_CAP_BYTES` bytes (worst-case bound). - * - Result limits come from `@maka/core/search.normalizeSearchLimit` - * (default 5, max `SEARCH_MAX_LIMIT=10`). - * - Total payload bytes (sum of snippets) capped at `TOTAL_PAYLOAD_CAP_BYTES`. - * - Per-result snippet capped at `SNIPPET_MAX_CODE_POINTS`. - * - Returns a success envelope containing `SearchResult[]` plus an explicit - * scan-truncation bit, with each result following the PR-SEARCH-0 shape and - * `source: 'thread'` and `target: { kind:'thread', sessionId, turnId? }` - * per PR-SEARCH-1.5, extended with stable message id, match kind, and - * timestamp anchors for Agent global search. `url` is left undefined - * (thread navigation does NOT use `maka://session`). - * - * Hard no-go (enforced by source gate at review): - * - No `fetch` / `XMLHttpRequest` / `new WebSocket` / `BrowserWindow`. - * - No `electron` imports — runs in main but stays Electron-agnostic via DI. - * - No FTS5 / SQLite / better-sqlite3. - * - No telemetry emission of query body. - * - No `maka://session` URI construction. - */ - -import { validateWorkspacePrivacyContext } from './incognito.js'; -import { redactSecrets } from './redaction.js'; -import { normalizeSearchLimit, normalizeSearchQuery } from './search.js'; -import type { SearchErrorReason, SearchResult, ThreadSearchMatchKind } from './search.js'; -import { collapseSessionRevisions } from './session-revisions.js'; -import type { SessionSummary, StoredMessage } from './session.js'; - -/** Max scan bytes per ToolResultMessage.content (JSON-serialized). */ -export const TOOL_RESULT_SCAN_CAP_BYTES = 10_240; - -/** Max code points retained in a result snippet. */ -export const SNIPPET_MAX_CODE_POINTS = 240; - -/** Half-window of snippet context characters on each side of the match. */ -export const SNIPPET_CONTEXT_HALF = 80; - -/** Cap on total snippet bytes (UTF-8) summed across all results. */ -export const TOTAL_PAYLOAD_CAP_BYTES = 64 * 1024; - -/** Max sessions scanned per query (newest first by lastMessageAt). */ -export const MAX_SESSIONS_SCANNED = 200; - -/** Max encoded bytes accepted for an opaque thread-search continuation. */ -export const THREAD_SEARCH_CURSOR_MAX_CHARS = 2_048; - -/** Returned source kind — locked to `'thread'` in v1. */ -export const THREAD_SOURCE = 'thread' as const; - -/** - * Pure dependency injection. Production wiring binds these to the real - * runtime; tests pass in-memory fakes. - * - * PR-SEARCH-2.5 (@xuan msg `2c55b975`): `getPrivacyContext` returns the - * Host-authority workspace privacy snapshot. Source is `unknown` - * because even though production wiring controls it, the helper - * itself MUST validate via `validateWorkspacePrivacyContext` — a - * future swap to a real authority (settings IPC etc.) must not bypass - * the validator. Renderer payloads MUST NOT reach this dep; production - * wiring binds it to a main-side authority only. - */ -export interface ThreadSearchDeps { - listSessions(): Promise; - readMessages(sessionId: string, abortSignal?: AbortSignal): Promise; - /** - * Host-authority workspace privacy snapshot. Returned as `unknown` - * deliberately — the helper validates the payload with - * `validateWorkspacePrivacyContext` before reading any field. Source - * MUST be Host-side (Runtime Host policy, Desktop settings authority, - * or workspace owner). Untrusted request payloads MUST NOT flow into this dep. - */ - getPrivacyContext(): Promise; -} - -export interface ThreadSearchSuccess { - readonly ok: true; - readonly results: SearchResult[]; - readonly truncated: boolean; - /** Present only when another complete session-scan page is reachable. */ - readonly nextCursor?: string; -} - -interface ThreadSearchCursor { - readonly version: 1; - readonly query: string; - readonly lastMessageAt: number; - readonly sessionId: string; -} - -/** - * Shared API surface. Desktop IPC and Runtime Host Agent tools wrap this - * helper with their own authority-owned dependencies. - * - * Accepts `unknown` because the IPC payload crosses a process boundary — - * TypeScript's `SearchRequest` annotation in the handler is compile-time - * only. A renderer can send anything; malformed input must fail closed - * with an error envelope. Dependency adapters project ordinary I/O - * failures before calling this function. Same defense pattern as PR-MEMORY-1 - * `validateMemoryWriteRequest` and PR-UI-IPC-1 baseUrl normalize - * (@xuan msg `2f1aba55` fixup). - */ -export async function runThreadSearch( - request: unknown, - deps: ThreadSearchDeps, - options: { - readonly activeSessionId?: string; - readonly excludeSessionIds?: ReadonlySet; - /** Desktop excludes archived tasks by default; Agent global history opts in explicitly. */ - readonly includeArchived?: boolean; - /** Keeps Agent global search from matching the user/tool text of its active turn. */ - readonly excludeTurnIds?: ReadonlySet; - readonly abortSignal?: AbortSignal; - } = {}, -): Promise { - if (options.abortSignal?.aborted) return abortedSearch(); - // L1: runtime shape guard. Renderer payload is untrusted across the - // IPC boundary. Null / non-object / missing fields → typed reject. - if (typeof request !== 'object' || request === null || Array.isArray(request)) { - return { ok: false, reason: 'invalid_query', message: 'search request must be an object' }; - } - const record = request as Record; - - // L2: source enum gate — this module only handles `'thread'`. The - // shape check above already rejected non-objects, so reading - // `record.source` is safe. - if (record.source !== THREAD_SOURCE) { - return { ok: false, reason: 'disabled', message: 'thread search only handles source=thread' }; - } - - // L3: query / limit normalization via @maka/core helpers — single - // chokepoint, never bypass. Both already guard typeof + finite. - const queryResult = normalizeSearchQuery(record.query); - if (!queryResult.ok) { - return queryResult; - } - const limitResult = normalizeSearchLimit(record.limit); - if (!limitResult.ok) { - return limitResult; - } - - // Matching a secret-shaped query against raw history would expose a - // hit/no-hit membership oracle even if the returned snippet were redacted. - // Reject such queries before touching the history authority, and match every - // searchable field only after applying the same redaction projection. - const redactedQuery = redactSecrets(queryResult.value); - if (redactedQuery !== queryResult.value) { - return { - ok: false, - reason: 'invalid_query', - message: 'Search query contains credential material and cannot be searched.', - }; - } - const queryFolded = foldForMatch(redactedQuery); - const cursorResult = decodeThreadSearchCursor(record.cursor, queryFolded); - if (!cursorResult.ok) return cursorResult; - - // L4: privacy gate (PR-SEARCH-2.5 @xuan `2c55b975`). Host-owned - // privacy authority. Two early-return paths share the same - // `reason:'incognito_active'` to avoid an extra UI state: - // - active incognito (user toggled on): `incognitoActive === true` - // - malformed authority payload (system fail-closed): validator - // reject treated as if incognito were active - // Both paths MUST NOT touch `listSessions` / `readMessages`. - // Distinguishing message wording is kept for diagnostics; consumers - // can read `message` if they need to differentiate. - const privacyPayload = await deps.getPrivacyContext(); - if (options.abortSignal?.aborted) return abortedSearch(); - const privacyResult = validateWorkspacePrivacyContext(privacyPayload); - if (!privacyResult.ok) { - return { - ok: false, - reason: 'incognito_active', - message: 'Search is disabled because workspace privacy state could not be verified.', - }; - } - if (privacyResult.value.incognitoActive) { - return { - ok: false, - reason: 'incognito_active', - message: 'Search is disabled while incognito is active.', - }; - } - - const maxResults = limitResult.value; - - const eligibleSessions = collapseSessionRevisions( - await deps.listSessions(), - options.activeSessionId, - ) - // Exclude fake-backend sessions. The rail still shows them (marked stale) - // because they are task records, but their transcripts are simulator output; - // returning fabricated text as a hit on the user's own history is worse than - // returning nothing. Retiring the backend (#3211) did not make that content - // real, so the filter stays. - .filter( - (session) => - session.backend !== 'fake' && - (options.includeArchived === true || !session.isArchived) && - !options.excludeSessionIds?.has(session.id), - ) - // Newest first by lastMessageAt; secondary by id for determinism. - .sort((a, b) => { - const ts = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0); - if (ts !== 0) return ts; - return a.id.localeCompare(b.id); - }); - if (options.abortSignal?.aborted) return abortedSearch(); - const remainingSessions = cursorResult.value - ? eligibleSessions.filter((session) => sessionIsAfterCursor(session, cursorResult.value!)) - : eligibleSessions; - const sessions = remainingSessions.slice(0, MAX_SESSIONS_SCANNED); - const hasMoreSessions = remainingSessions.length > sessions.length; - - const results: SearchResult[] = []; - let totalBytes = 0; - let truncated = hasMoreSessions; - let scannedCompletePage = true; - - sessionScan: for (const session of sessions) { - if (options.abortSignal?.aborted) return abortedSearch(); - if (results.length >= maxResults) { - truncated = true; - scannedCompletePage = false; - break; - } - - const searchableTitle = redactSecrets(session.name); - const titleHit = findMatch(searchableTitle, queryFolded); - if (titleHit !== undefined) { - const snippet = capCodePoints( - buildSnippet(searchableTitle, titleHit, SNIPPET_CONTEXT_HALF), - SNIPPET_MAX_CODE_POINTS, - ); - const snippetBytes = Buffer.byteLength(snippet, 'utf8'); - if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { - truncated = true; - scannedCompletePage = false; - break; - } - totalBytes += snippetBytes; - results.push({ - source: THREAD_SOURCE, - title: searchableTitle, - summary: '任务标题', - snippet, - target: { - kind: 'thread', - sessionId: session.id, - matchKind: 'session_title', - }, - }); - if (results.length >= maxResults) { - truncated = true; - scannedCompletePage = false; - break; - } - } - - const messages = await deps.readMessages(session.id, options.abortSignal); - if (options.abortSignal?.aborted) return abortedSearch(); - if (!messages) continue; - - for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { - if (messageIndex > 0 && messageIndex % 256 === 0) { - await new Promise((resolve) => setImmediate(resolve)); - } - if (options.abortSignal?.aborted) return abortedSearch(); - const message = messages[messageIndex]!; - if (results.length >= maxResults) { - truncated = true; - scannedCompletePage = false; - break sessionScan; - } - - const turnId = (message as { turnId?: string }).turnId; - if (session.id === options.activeSessionId && turnId && options.excludeTurnIds?.has(turnId)) { - continue; - } - - const rawCandidate = collectSearchableText(message); - if (rawCandidate === undefined) continue; - const candidate = redactSecrets(rawCandidate); - - const hit = findMatch(candidate, queryFolded); - if (hit === undefined) continue; - - // Build the snippet, redact secrets, cap length. - const snippet = capCodePoints( - redactSecrets(buildSnippet(candidate, hit, SNIPPET_CONTEXT_HALF)), - SNIPPET_MAX_CODE_POINTS, - ); - - const snippetBytes = Buffer.byteLength(snippet, 'utf8'); - if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { - truncated = true; - scannedCompletePage = false; - break sessionScan; - } - totalBytes += snippetBytes; - - results.push({ - source: THREAD_SOURCE, - title: redactSecrets(session.name), - summary: formatSearchResultSummary(message), - snippet, - // PR-SEARCH-1.5: navigation target via discriminated union; no - // `url` field for thread results (maka://session is deferred). - target: { - kind: 'thread', - sessionId: session.id, - ...(turnId ? { turnId } : {}), - sequence: messageIndex, - messageId: message.id, - matchKind: threadSearchMatchKind(message), - messageTimestamp: message.ts, - }, - }); - } - } - - if (truncated && results.length > 0) { - results[results.length - 1] = { ...results[results.length - 1]!, truncated: true }; - } - - const lastSession = sessions.at(-1); - const nextCursor = - hasMoreSessions && scannedCompletePage && lastSession - ? encodeThreadSearchCursor({ - version: 1, - query: queryFolded, - lastMessageAt: sessionSortTime(lastSession), - sessionId: lastSession.id, - }) - : undefined; - return { ok: true, results, truncated, ...(nextCursor ? { nextCursor } : {}) }; -} - -function abortedSearch(): { ok: false; reason: 'aborted'; message: string } { - return { ok: false, reason: 'aborted', message: 'History search was aborted.' }; -} - -function decodeThreadSearchCursor( - input: unknown, - query: string, -): - | { readonly ok: true; readonly value: ThreadSearchCursor | undefined } - | { readonly ok: false; readonly reason: 'invalid_query'; readonly message: string } { - if (input === undefined) return { ok: true, value: undefined }; - if ( - typeof input !== 'string' || - input.length === 0 || - input.length > THREAD_SEARCH_CURSOR_MAX_CHARS || - input.trim() !== input - ) { - return invalidThreadSearchCursor(); - } - try { - const decoded = Buffer.from(input, 'base64url').toString('utf8'); - if (Buffer.from(decoded, 'utf8').toString('base64url') !== input) { - return invalidThreadSearchCursor(); - } - const value: unknown = JSON.parse(decoded); - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return invalidThreadSearchCursor(); - } - const cursor = value as Record; - if ( - cursor.version !== 1 || - cursor.query !== query || - typeof cursor.lastMessageAt !== 'number' || - !Number.isFinite(cursor.lastMessageAt) || - typeof cursor.sessionId !== 'string' || - cursor.sessionId.length === 0 || - cursor.sessionId.length > 256 - ) { - return invalidThreadSearchCursor(); - } - return { - ok: true, - value: { - version: 1, - query, - lastMessageAt: cursor.lastMessageAt, - sessionId: cursor.sessionId, - }, - }; - } catch { - return invalidThreadSearchCursor(); - } -} - -function invalidThreadSearchCursor() { - return { - ok: false as const, - reason: 'invalid_query' as const, - message: 'Search cursor is invalid or belongs to another query.', - }; -} - -function encodeThreadSearchCursor(cursor: ThreadSearchCursor): string { - return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); -} - -function sessionSortTime(session: SessionSummary): number { - return session.lastMessageAt ?? 0; -} - -function sessionIsAfterCursor(session: SessionSummary, cursor: ThreadSearchCursor): boolean { - const timestamp = sessionSortTime(session); - return ( - timestamp < cursor.lastMessageAt || - (timestamp === cursor.lastMessageAt && session.id.localeCompare(cursor.sessionId) > 0) - ); -} - -/** Stable result classification shared by Desktop navigation and Agent tools. */ -export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatchKind { - switch (message.type) { - case 'user': - return 'user_message'; - case 'assistant': - return 'assistant_message'; - case 'tool_call': - return 'tool_intent'; - case 'tool_result': - return 'tool_result'; - case 'permission_decision': - case 'token_usage': - case 'turn_state': - case 'workhub_coordination': - case 'system_note': - throw new Error(`Message type ${message.type} is not searchable`); - } -} - -export function formatSearchResultSummary(message: StoredMessage): string { - switch (message.type) { - case 'user': - return '用户消息'; - case 'assistant': - return '助手回复'; - case 'tool_call': - return message.displayName - ? `工具调用:${message.displayName}` - : `工具调用:${message.toolName}`; - case 'tool_result': - return message.isError ? '工具结果:失败' : '工具结果:成功'; - case 'permission_decision': - return '权限记录'; - case 'token_usage': - return '用量记录'; - case 'turn_state': - return '回合状态'; - case 'workhub_coordination': - return 'WorkHub 协调记录'; - case 'system_note': - return '系统记录'; - } -} - -/** - * Extract user-visible answer text from a stored message. Returns `undefined` - * for excluded message kinds (system notes, token usage, turn state, - * permission decisions). This is the only "what counts as searchable - * transcript content" gate; adding new searchable surfaces requires - * extending this switch + a corresponding test. - * - * For ToolResultMessage, the `content` is JSON-serialized and capped - * at `TOOL_RESULT_SCAN_CAP_BYTES` so a 100 MB tool result doesn't - * inflate scan time. - */ -export function collectSearchableText(message: StoredMessage): string | undefined { - switch (message.type) { - case 'user': - // Prefer the human-facing view so skill-invocation envelopes do not - // dominate local search hits for what the user actually typed. - return message.displayText ?? message.text; - case 'assistant': - // Search result snippets are a transcript surface. Assistant - // reasoning/thinking may be rendered separately in the live chat, - // but it is not answer text and must not leak into local search. - return message.text; - case 'tool_call': - // PR-SEARCH-2 review fixup (@xuan `2f1aba55`): index ONLY - // `intent` — the user-visible description of what the tool call - // is doing. `toolName` (e.g. `Bash`) and `displayName` are - // internal labels and would let searches for `Bash` match every - // bash invocation regardless of intent. The PR-SEARCH-1 plan - // already locked `intent` as the only searchable field on - // `ToolCallMessage`; the previous draft over-indexed by mistake. - return message.intent && message.intent.length > 0 ? message.intent : undefined; - case 'tool_result': { - // Bounded JSON-serialize. The cap protects against pathological - // multi-MB tool outputs (file dumps, etc.). - let serialized: string; - try { - serialized = JSON.stringify(message.content); - } catch { - return undefined; - } - if (Buffer.byteLength(serialized, 'utf8') > TOOL_RESULT_SCAN_CAP_BYTES) { - // Truncate to the cap. Use byte-safe slice via Buffer. - const buf = Buffer.from(serialized, 'utf8').subarray(0, TOOL_RESULT_SCAN_CAP_BYTES); - return buf.toString('utf8'); - } - return serialized; - } - case 'permission_decision': - case 'token_usage': - case 'turn_state': - case 'workhub_coordination': - case 'system_note': - // Coordination records are rendered by WorkHub, but the reserved - // Coordination Session is intentionally outside general thread search. - // The remaining cases are not user-typed / not user-visible content. - return undefined; - } -} - -/** - * NFC + lowercase canonicalization for substring match. NOT a security - * boundary — purely for case-insensitive + composed-form matching. - * - * Public for tests; production callers use `runThreadSearch` only. - */ -export function foldForMatch(value: string): string { - return value.normalize('NFC').toLowerCase(); -} - -/** - * Find the index of the first occurrence of `queryFolded` in `text` - * (after the same fold operation). Returns the index in the original - * (unfolded) text — JS `String.prototype.toLowerCase()` preserves - * code-point indexing for ASCII and most CJK, which is what we need - * for snippet extraction. Returns `undefined` on no match. - */ -export function findMatch(text: string, queryFolded: string): number | undefined { - const folded = foldForMatch(text); - const idx = folded.indexOf(queryFolded); - return idx >= 0 ? idx : undefined; -} - -/** - * Extract a context window around the match. Pure substring + ellipsis - * marker; no HTML, no markup. Caller is responsible for redaction + - * length cap afterward. - */ -export function buildSnippet(text: string, matchIndex: number, halfWindow: number): string { - const start = Math.max(0, matchIndex - halfWindow); - const end = Math.min(text.length, matchIndex + halfWindow); - const prefix = start > 0 ? '…' : ''; - const suffix = end < text.length ? '…' : ''; - return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix; -} - -/** - * Cap a string to at most `maxCodePoints` code points. Uses - * `Array.from` so surrogate pairs (emoji) are not split. Appends - * an ellipsis when truncated. - */ -export function capCodePoints(value: string, maxCodePoints: number): string { - const codePoints = Array.from(value); - if (codePoints.length <= maxCodePoints) return value; - return codePoints.slice(0, maxCodePoints - 1).join('') + '…'; -} diff --git a/packages/core/src/transcript-search.ts b/packages/core/src/transcript-search.ts new file mode 100644 index 0000000000..f98cd0fe52 --- /dev/null +++ b/packages/core/src/transcript-search.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Primitives shared by the retrieval surfaces that read stored transcripts. + * + * These live apart from any one retrieval implementation because more than one + * needs them and they must not drift: + * + * - Recall matches terms against folded, redacted transcript text and labels + * what it matched with the same message-kind vocabulary. + * - The Agent's global history search folds session headers and records to + * the same form before scanning them. + * + * Nothing here touches storage, privacy state, or the search contract's + * envelopes — these are pure functions and constants, so a caller can adopt + * one without inheriting a retrieval strategy. + */ + +import type { ThreadSearchMatchKind } from './search.js'; +import type { StoredMessage } from './session.js'; + +/** Max sessions scanned per query (newest first by lastMessageAt). */ +export const MAX_SESSIONS_SCANNED = 200; + +/** + * NFC + lowercase canonicalization for substring match. NOT a security + * boundary — purely for case-insensitive + composed-form matching. + */ +export function foldForMatch(value: string): string { + return value.normalize('NFC').toLowerCase(); +} + +/** Stable result classification shared by Desktop navigation and Agent tools. */ +export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatchKind { + switch (message.type) { + case 'user': + return 'user_message'; + case 'assistant': + return 'assistant_message'; + case 'tool_call': + return 'tool_intent'; + case 'tool_result': + return 'tool_result'; + case 'permission_decision': + case 'token_usage': + case 'turn_state': + case 'workhub_coordination': + case 'system_note': + throw new Error(`Message type ${message.type} is not searchable`); + } +} diff --git a/packages/runtime-host/protocol-compatible-changes/recall-query-operation.json b/packages/runtime-host/protocol-compatible-changes/recall-query-operation.json new file mode 100644 index 0000000000..f92a2547b2 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/recall-query-operation.json @@ -0,0 +1,8 @@ +{ + "epoch": 167, + "files": [ + "packages/runtime-host/src/protocol/recall.ts", + "packages/runtime-host/src/protocol/operations.ts" + ], + "reason": "Adds the recall.query operation spec and registers its key. A new operation key is a compatible extension: a peer that predates it never sends recall.query and never receives one, so every existing request and response decodes byte-for-byte as before. Nothing already on the wire changes shape or meaning." +} diff --git a/packages/runtime-host/src/__tests__/recall-coordinator.test.ts b/packages/runtime-host/src/__tests__/recall-coordinator.test.ts new file mode 100644 index 0000000000..3f33418206 --- /dev/null +++ b/packages/runtime-host/src/__tests__/recall-coordinator.test.ts @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { RecallDeps } from '@maka/core/recall'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import { HostRecallCoordinator, projectRecallResult } from '../server/recall-coordinator.js'; + +function session(id: string, name: string, lastMessageAt = 1): SessionSummary { + return { + id, + name, + isFlagged: false, + isArchived: false, + backend: 'runtime-host', + lastMessageAt, + } as unknown as SessionSummary; +} + +function user(id: string, turnId: string, text: string, ts = 1): StoredMessage { + return { type: 'user', id, turnId, ts, text } as StoredMessage; +} + +function deps(messages: readonly StoredMessage[], overrides: Partial = {}): RecallDeps { + return { + listSessions: async () => [session('s1', 'deploy notes')], + readMessages: async () => [...messages], + getPrivacyContext: async () => ({ incognitoActive: false }), + ...overrides, + }; +} + +test('serves a recall query end to end over the Host deps', async () => { + const coordinator = new HostRecallCoordinator( + deps([user('m1', 't1', 'unrelated opening'), user('m2', 't1', 'run the deploy script now')]), + ); + const outcome = await coordinator.handlers['recall.query']( + { terms: ['deploy'] }, + undefined as never, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok) return; + assert.equal(outcome.result.ok, true); + if (!outcome.result.ok) return; + assert.equal(outcome.result.passages.length, 1); + const passage = outcome.result.passages[0]!; + assert.equal(passage.sessionId, 's1'); + assert.equal(passage.sessionTitle, 'deploy notes'); + assert.equal(passage.anchorMessageId, 'm2'); + // The coordinate a Client scrolls to. `m2` is the second message, so an + // off-by-one or a reused ranking score here would send a click to the wrong row. + assert.equal(passage.sequence, 1); +}); + +test('a Client search is not inside a turn, so nothing is excluded from it', async () => { + // The model's tool must not surface its own turn back as corroboration. A + // Client search has no turn, so the same message stays reachable — this + // pins that the coordinator does not accidentally inherit the tool's rule. + const coordinator = new HostRecallCoordinator( + deps([user('m1', 't1', 'run the deploy script now')]), + ); + const outcome = await coordinator.handlers['recall.query']( + { terms: ['deploy'] }, + undefined as never, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok || !outcome.result.ok) return assert.fail('expected a passage'); + assert.equal(outcome.result.passages.length, 1); +}); + +test('a failure reason crosses as a result, not as a Host error', async () => { + // Privacy refusal is a fact about the corpus, not a fault in this operation; + // answering it as `internal_failure` would tell a Client to retry. + const coordinator = new HostRecallCoordinator( + deps([user('m1', 't1', 'anything')], { + getPrivacyContext: async () => ({ incognitoActive: true }), + }), + ); + const outcome = await coordinator.handlers['recall.query']( + { terms: ['deploy'] }, + undefined as never, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok || outcome.result.ok) return assert.fail('expected a refusal envelope'); + assert.equal(outcome.result.reason, 'incognito_active'); + assert.ok(outcome.result.message.length > 0, 'the refusal must explain itself'); +}); + +test('a malformed request is refused without touching the corpus', async () => { + let read = 0; + const coordinator = new HostRecallCoordinator( + deps([], { + listSessions: async () => { + read += 1; + return []; + }, + }), + ); + const outcome = await coordinator.handlers['recall.query']( + { terms: [] } as never, + undefined as never, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok) return assert.fail('a refused query is not a Host failure'); + assert.equal(outcome.result.ok, false); + if (outcome.result.ok) return; + assert.equal(outcome.result.reason, 'invalid_query'); + assert.equal(read, 0, 'a rejected query must not read the corpus'); +}); + +test('projects the core envelope into the wire shape, keeping the navigation index', () => { + const projected = projectRecallResult({ + ok: true, + facts: [{ content: 'a fact', kind: 'fact', observedAt: 1 }], + passages: [ + { + sessionId: 's1', + sessionTitle: 'title', + turnId: 't1', + anchorMessageId: 'm2', + sequence: 3, + messages: [ + { + messageId: 'm2', + role: 'assistant', + matchKind: 'assistant_message', + text: 'text', + timestamp: 1, + isAnchor: true, + materials: [ + { name: 'a.png', kind: 'image', mimeType: 'image/png', bytes: 3, resource: 'ref' }, + { + name: 'b.pdf', + kind: 'pdf', + mimeType: 'application/pdf', + bytes: 4, + sourceSessionId: 's0', + materialId: 'mat-1', + }, + ], + }, + ], + matchedTerms: ['text'], + score: 2.5, + hasMoreBefore: true, + hasMoreAfter: false, + }, + ], + gaps: 'Searched 1 Session(s).', + scannedFully: false, + }); + assert.equal(projected.ok, true); + if (!projected.ok) return; + assert.equal(projected.searchedEverySession, false); + const passage = projected.passages[0]!; + assert.equal(passage.sequence, 3); + assert.deepEqual(passage.messages[0]!.materials, [ + { name: 'a.png', kind: 'image', mimeType: 'image/png', bytes: 3, resource: 'ref' }, + { + name: 'b.pdf', + kind: 'pdf', + mimeType: 'application/pdf', + bytes: 4, + sourceSessionId: 's0', + materialId: 'mat-1', + }, + ]); +}); + +test('projects a failure without inventing a success envelope', () => { + assert.deepEqual(projectRecallResult({ ok: false, reason: 'not_found', message: 'nope' }), { + ok: false, + reason: 'not_found', + message: 'nope', + }); +}); diff --git a/packages/runtime-host/src/__tests__/recall-protocol.test.ts b/packages/runtime-host/src/__tests__/recall-protocol.test.ts new file mode 100644 index 0000000000..a626e5dd61 --- /dev/null +++ b/packages/runtime-host/src/__tests__/recall-protocol.test.ts @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + HOST_OPERATION_SPECS, + REMOTE_OWNER_OPERATION_GRANTS, + decodeRequestFrame, + decodeResponseFrame, +} from '../protocol/index.js'; + +const successResult = { + ok: true as const, + facts: [{ content: 'the workspace deploys with make', kind: 'fact', observedAt: 1 }], + passages: [ + { + sessionId: 'session-1', + sessionTitle: 'deploy notes', + turnId: 'turn-2', + anchorMessageId: 'message-7', + sequence: 7, + messages: [ + { + messageId: 'message-7', + role: 'assistant' as const, + matchKind: 'assistant_message', + text: 'run the deploy script', + timestamp: 1_700_000_000_000, + isAnchor: true, + }, + ], + matchedTerms: ['deploy'], + score: 1.25, + hasMoreBefore: false, + hasMoreAfter: true, + }, + ], + gaps: 'Searched 1 Session(s).', + searchedEverySession: true, +}; + +test('recall.query is a ready read-only query gated to remote owners deliberately', () => { + const spec = HOST_OPERATION_SPECS['recall.query']; + assert.equal(spec.mode, 'query'); + assert.equal(spec.availability, 'ready'); + assert.deepEqual( + decodeRequestFrame({ + requestId: 'request-1', + operation: 'recall.query', + input: { terms: ['deploy'] }, + }), + { + requestId: 'request-1', + operation: 'recall.query', + input: { terms: ['deploy'] }, + }, + ); + // A Client searches its own Host's history. The grant is what lets a remote + // owner connect and query; without it the operation would be local-only. + assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('recall.query'), true); +}); + +test('recall.query round-trips a success envelope including the navigation index', () => { + assert.deepEqual( + decodeResponseFrame({ + requestId: 'request-1', + operation: 'recall.query', + ok: true, + result: successResult, + }), + { + requestId: 'request-1', + operation: 'recall.query', + ok: true, + result: successResult, + }, + ); + // The whole point of the field: a Client needs the anchor's transcript index + // to scroll to the hit, not merely its identity. Comparing the decoded result + // keeps the assertion on the field rather than on the union's shape. + assert.deepEqual(HOST_OPERATION_SPECS['recall.query'].decodeOutput(successResult), successResult); +}); + +test('recall.query carries a material as an address or a location, never both', () => { + const base = successResult.passages[0]!; + const withAddress = { + ...successResult, + passages: [ + { + ...base, + messages: [ + { + ...base.messages[0]!, + text: '', + materials: [ + { + name: 'diagram.png', + kind: 'image' as const, + mimeType: 'image/png', + bytes: 12, + resource: 'maka://attachment/session-1/diagram.png', + }, + ], + }, + ], + }, + ], + }; + assert.doesNotThrow(() => + decodeResponseFrame({ + requestId: 'request-1', + operation: 'recall.query', + ok: true, + result: withAddress, + }), + ); + // An address means "read this now"; a location means "ask for it". Carrying + // both would offer two answers to one question, so the frame is refused. + assert.throws(() => + decodeResponseFrame({ + requestId: 'request-1', + operation: 'recall.query', + ok: true, + result: { + ...withAddress, + passages: [ + { + ...base, + messages: [ + { + ...withAddress.passages[0]!.messages[0]!, + materials: [ + { + ...withAddress.passages[0]!.messages[0]!.materials[0]!, + sourceSessionId: 'session-2', + materialId: 'material-3', + }, + ], + }, + ], + }, + ], + }, + }), + ); +}); + +test('recall.query refuses a malformed input and a negative navigation index', () => { + assert.throws(() => + decodeRequestFrame({ requestId: 'request-1', operation: 'recall.query', input: {} }), + ); + assert.throws(() => + decodeRequestFrame({ + requestId: 'request-1', + operation: 'recall.query', + input: { terms: [] }, + }), + ); + assert.throws(() => + decodeRequestFrame({ + requestId: 'request-1', + operation: 'recall.query', + input: { terms: ['deploy'], limit: 0 }, + }), + ); + assert.throws(() => + decodeResponseFrame({ + requestId: 'request-1', + operation: 'recall.query', + ok: true, + result: { + ...successResult, + passages: [{ ...successResult.passages[0]!, sequence: -1 }], + }, + }), + ); +}); + +test('recall.query reports its own failure vocabulary over the wire', () => { + assert.deepEqual( + HOST_OPERATION_SPECS['recall.query'].decodeOutput({ + ok: false, + reason: 'incognito_active', + message: 'Recall is unavailable.', + }), + { ok: false, reason: 'incognito_active', message: 'Recall is unavailable.' }, + ); + assert.throws(() => + HOST_OPERATION_SPECS['recall.query'].decodeOutput({ + ok: false, + reason: 'malformed', + message: 'nope', + }), + ); +}); diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 0a4590609c..1861d33abb 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -67,6 +67,7 @@ import { SKILL_CATALOG_OPERATION_SPECS } from './skill-catalog.js'; import { TURN_OPERATION_SPECS } from './turn.js'; import { USAGE_PRICING_OPERATION_SPECS } from './usage-pricing.js'; import { WEB_SEARCH_OPERATION_SPECS } from './web-search.js'; +import { RECALL_OPERATION_SPECS } from './recall.js'; import { WORKHUB_COORDINATION_OPERATION_SPECS } from './workhub-coordination.js'; export type { @@ -185,6 +186,7 @@ export * from './session-effects.js'; export * from './skill-catalog.js'; export * from './usage-pricing.js'; export * from './web-search.js'; +export * from './recall.js'; export * from './workspace.js'; export const HOST_OPERATION_SPECS = composeOperationSpecMaps( @@ -228,6 +230,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( CLIENT_CAPABILITY_OPERATION_SPECS, WEB_SEARCH_OPERATION_SPECS, NETWORK_PROXY_OPERATION_SPECS, + RECALL_OPERATION_SPECS, CONFIGURATION_OPERATION_SPECS, WORKHUB_COORDINATION_OPERATION_SPECS, PLUGIN_PLATFORM_OPERATION_SPECS, @@ -316,6 +319,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'runtime.policy.mutate', 'runtime.policy.network-proxy.update', 'runtime.policy.query', + 'recall.query', 'runtime.resource.controller.acquire', 'runtime.resource.controller.control', 'runtime.resource.controller.release', diff --git a/packages/runtime-host/src/protocol/recall.ts b/packages/runtime-host/src/protocol/recall.ts new file mode 100644 index 0000000000..ff914ccc19 --- /dev/null +++ b/packages/runtime-host/src/protocol/recall.ts @@ -0,0 +1,453 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * `recall.query` — the retrieval surface a Client can ask for directly. + * + * Recall runs inside the Host because that is where the corpus lives: the + * Session manager, the distilled-fact store, and the material fetch are all + * Host-owned. A Client cannot perform it itself without pulling every + * transcript across the boundary, which is exactly the arrangement this + * operation replaces. + * + * The wire shape mirrors what the model's `Recall` tool already returns + * (`@maka/runtime/recall-tools`), with this protocol's camelCase field naming. + * That is deliberate: one recall answer, two presentations. A UI renders + * passages as navigable results and a model reads them as context, and neither + * gets a second implementation to drift from. + */ + +import { + RECALL_MAX_LIMIT, + RECALL_MAX_TERMS, + RECALL_TOTAL_PAYLOAD_CAP_BYTES, + type RecallFailureReason, +} from '@maka/core/recall'; +import { SEARCH_QUERY_MAX_CHARS } from '@maka/core/search'; +import { + requireCount, + requireEncodedByteLimit, + requireEntityId, + requireShapedRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +export const RECALL_QUERY_TERM_MAX_CHARS = SEARCH_QUERY_MAX_CHARS; + +/** + * Byte ceiling for one term. Core measures a term in code points + * (`Array.from(term).length`), so the wire bound is that count times the + * widest UTF-8 encoding of one code point. A term that passes here can still + * be rejected by core on the character count; this only stops a frame that + * could not represent a legal term at all. + */ +const RECALL_QUERY_TERM_MAX_BYTES = RECALL_QUERY_TERM_MAX_CHARS * 4; + +export const RECALL_QUERY_QUESTION_MAX_BYTES = 2 * 1024; +export const RECALL_QUERY_TEXT_MAX_BYTES = 32 * 1024; +export const RECALL_QUERY_MATERIAL_NAME_MAX_BYTES = 1024; +export const RECALL_QUERY_RESOURCE_MAX_BYTES = 1024; + +/** + * Encoded ceiling for one envelope. Recall caps its own payload at + * `RECALL_TOTAL_PAYLOAD_CAP_BYTES`, but that counts passage text only; the + * envelope adds field names, message ids, timestamps and materials on top. + * This bound is that cap plus generous structural headroom, so it rejects a + * frame that no honest Host could have produced without constraining one that + * did. + */ +export const RECALL_QUERY_RESULT_MAX_BYTES = RECALL_TOTAL_PAYLOAD_CAP_BYTES + 64 * 1024; + +const RECALL_FAILURE_REASONS: readonly RecallFailureReason[] = [ + 'invalid_query', + 'incognito_active', + 'not_found', + 'aborted', +]; + +const RECALL_ROLES = ['user', 'assistant', 'tool'] as const; +const RECALL_MATERIAL_KINDS = ['image', 'pdf', 'doc', 'code', 'other'] as const; + +const ERROR_CODES = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'internal_failure', +] as const; + +export interface RecallQueryInput { + /** Literal terms, matched case-insensitively as substrings and OR-combined. */ + readonly terms: readonly string[]; + readonly question?: string; + readonly limit?: number; + /** Restrict recall to one Session. */ + readonly sessionId?: string; + readonly since?: number; + readonly until?: number; +} + +export interface RecallQueryFact { + readonly content: string; + readonly kind: string; + readonly observedAt: number; +} + +export interface RecallQueryMaterial { + readonly name: string; + readonly kind: (typeof RECALL_MATERIAL_KINDS)[number]; + readonly mimeType: string; + readonly bytes: number; + /** Address `Read` accepts; present only when `Read` would answer it here. */ + readonly resource?: string; + /** Present instead of `resource`: ask for the file by this pair. */ + readonly sourceSessionId?: string; + readonly materialId?: string; +} + +export interface RecallQueryPassageMessage { + readonly messageId: string; + readonly role: (typeof RECALL_ROLES)[number]; + readonly matchKind: string; + readonly text: string; + readonly timestamp: number; + readonly isAnchor: boolean; + readonly materials?: readonly RecallQueryMaterial[]; +} + +export interface RecallQueryPassage { + readonly sessionId: string; + readonly sessionTitle: string; + readonly turnId?: string; + readonly anchorMessageId: string; + /** + * The anchor's index in its Session transcript — the coordinate a Client + * scrolls to. See `RecallPassage.sequence` in `@maka/core/recall`. + */ + readonly sequence: number; + readonly messages: readonly RecallQueryPassageMessage[]; + readonly matchedTerms: readonly string[]; + readonly score: number; + readonly lastMessageAt?: number; + readonly hasMoreBefore: boolean; + readonly hasMoreAfter: boolean; + readonly truncated?: boolean; +} + +export type RecallQueryResult = + | { + readonly ok: true; + readonly facts: readonly RecallQueryFact[]; + readonly passages: readonly RecallQueryPassage[]; + readonly gaps: string; + readonly searchedEverySession: boolean; + } + | { + readonly ok: false; + readonly reason: RecallFailureReason; + readonly message: string; + }; + +export const RECALL_OPERATION_SPECS = { + 'recall.query': defineOperation< + RecallQueryInput, + RecallQueryResult, + (typeof ERROR_CODES)[number] + >({ + mode: 'query', + availability: 'ready', + errors: ERROR_CODES, + decodeInput: decodeRecallQueryInput, + decodeOutput: decodeRecallQueryResult, + }), +} as const; + +function decodeRecallQueryInput(value: unknown): RecallQueryInput { + const record = requireShapedRecord( + value, + 'Recall query input', + ['terms'], + ['question', 'limit', 'sessionId', 'since', 'until'], + ); + if (!Array.isArray(record.terms) || record.terms.length === 0) { + throw invalidProtocolFrame('Invalid Recall terms'); + } + if (record.terms.length > RECALL_MAX_TERMS) { + throw invalidProtocolFrame('Too many Recall terms'); + } + const terms = record.terms.map((term) => + requireUtf8String(term, 'Recall term', RECALL_QUERY_TERM_MAX_BYTES), + ); + const limit = record.limit === undefined ? undefined : requireCount(record.limit, 'Recall limit'); + if (limit !== undefined && (limit < 1 || limit > RECALL_MAX_LIMIT)) { + throw invalidProtocolFrame('Invalid Recall limit'); + } + return { + terms, + ...(record.question === undefined + ? {} + : { + question: requireUtf8String( + record.question, + 'Recall question', + RECALL_QUERY_QUESTION_MAX_BYTES, + ), + }), + ...(limit === undefined ? {} : { limit }), + ...(record.sessionId === undefined + ? {} + : { sessionId: requireEntityId(record.sessionId, 'Recall sessionId') }), + ...(record.since === undefined ? {} : { since: requireCount(record.since, 'Recall since') }), + ...(record.until === undefined ? {} : { until: requireCount(record.until, 'Recall until') }), + }; +} + +/** + * Bounded text that may be empty. Transcript text is legitimately empty when a + * message carried only files, and a Session title is empty for an unnamed + * Session; both are legal answers, so they must not be rejected as malformed. + */ +function requireBoundedText(value: unknown, label: string, maxBytes: number): string { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > maxBytes) { + throw invalidProtocolFrame(`Invalid ${label}`); + } + return value; +} + +function decodeRecallQueryResult(value: unknown): RecallQueryResult { + requireEncodedByteLimit(value, 'Recall query result', RECALL_QUERY_RESULT_MAX_BYTES); + const record = requireShapedRecord( + value, + 'Recall query result', + ['ok'], + ['facts', 'passages', 'gaps', 'searchedEverySession', 'reason', 'message'], + ); + if (record.ok === false) { + const exact = requireShapedRecord( + record, + 'Recall error result', + ['ok', 'reason', 'message'], + [], + ); + if ( + typeof exact.reason !== 'string' || + !RECALL_FAILURE_REASONS.includes(exact.reason as RecallFailureReason) + ) { + throw invalidProtocolFrame('Invalid Recall failure reason'); + } + return { + ok: false, + reason: exact.reason as RecallFailureReason, + message: requireUtf8String( + exact.message, + 'Recall failure message', + RECALL_QUERY_TEXT_MAX_BYTES, + ), + }; + } + if (record.ok !== true) throw invalidProtocolFrame('Invalid Recall query result kind'); + const exact = requireShapedRecord( + record, + 'Recall success result', + ['ok', 'facts', 'passages', 'gaps', 'searchedEverySession'], + [], + ); + if (!Array.isArray(exact.facts) || !Array.isArray(exact.passages)) { + throw invalidProtocolFrame('Invalid Recall success result'); + } + if (typeof exact.searchedEverySession !== 'boolean') { + throw invalidProtocolFrame('Invalid Recall searchedEverySession'); + } + return { + ok: true, + facts: exact.facts.map((fact) => decodeFact(fact)), + passages: exact.passages.map((passage) => decodePassage(passage)), + gaps: requireUtf8String(exact.gaps, 'Recall gaps', RECALL_QUERY_TEXT_MAX_BYTES), + searchedEverySession: exact.searchedEverySession, + }; +} + +function decodeFact(value: unknown): RecallQueryFact { + const record = requireShapedRecord(value, 'Recall fact', ['content', 'kind', 'observedAt'], []); + return { + content: requireUtf8String(record.content, 'Recall fact content', RECALL_QUERY_TEXT_MAX_BYTES), + kind: requireUtf8String(record.kind, 'Recall fact kind', RECALL_QUERY_MATERIAL_NAME_MAX_BYTES), + observedAt: requireCount(record.observedAt, 'Recall fact observedAt'), + }; +} + +function decodePassage(value: unknown): RecallQueryPassage { + const record = requireShapedRecord( + value, + 'Recall passage', + [ + 'sessionId', + 'sessionTitle', + 'anchorMessageId', + 'sequence', + 'messages', + 'matchedTerms', + 'score', + 'hasMoreBefore', + 'hasMoreAfter', + ], + ['turnId', 'lastMessageAt', 'truncated'], + ); + if (!Array.isArray(record.messages) || !Array.isArray(record.matchedTerms)) { + throw invalidProtocolFrame('Invalid Recall passage messages'); + } + if (typeof record.score !== 'number' || !Number.isFinite(record.score)) { + throw invalidProtocolFrame('Invalid Recall passage score'); + } + if (typeof record.hasMoreBefore !== 'boolean' || typeof record.hasMoreAfter !== 'boolean') { + throw invalidProtocolFrame('Invalid Recall passage continuation flags'); + } + return { + sessionId: requireEntityId(record.sessionId, 'Recall passage sessionId'), + sessionTitle: requireBoundedText( + record.sessionTitle, + 'Recall passage sessionTitle', + RECALL_QUERY_TEXT_MAX_BYTES, + ), + ...(record.turnId === undefined + ? {} + : { turnId: requireEntityId(record.turnId, 'Recall passage turnId') }), + anchorMessageId: requireEntityId(record.anchorMessageId, 'Recall passage anchorMessageId'), + sequence: requireCount(record.sequence, 'Recall passage sequence'), + messages: record.messages.map((message) => decodePassageMessage(message)), + matchedTerms: record.matchedTerms.map((term) => + requireUtf8String(term, 'Recall matched term', RECALL_QUERY_TERM_MAX_BYTES), + ), + score: record.score, + ...(record.lastMessageAt === undefined + ? {} + : { lastMessageAt: requireCount(record.lastMessageAt, 'Recall passage lastMessageAt') }), + hasMoreBefore: record.hasMoreBefore, + hasMoreAfter: record.hasMoreAfter, + ...(record.truncated === undefined + ? {} + : record.truncated === true + ? { truncated: true as const } + : (() => { + throw invalidProtocolFrame('Invalid Recall passage truncated flag'); + })()), + }; +} + +function decodePassageMessage(value: unknown): RecallQueryPassageMessage { + const record = requireShapedRecord( + value, + 'Recall passage message', + ['messageId', 'role', 'matchKind', 'text', 'timestamp', 'isAnchor'], + ['materials'], + ); + if ( + typeof record.role !== 'string' || + !RECALL_ROLES.includes(record.role as (typeof RECALL_ROLES)[number]) + ) { + throw invalidProtocolFrame('Invalid Recall passage message role'); + } + if (typeof record.isAnchor !== 'boolean') { + throw invalidProtocolFrame('Invalid Recall passage message anchor flag'); + } + if (record.materials !== undefined && !Array.isArray(record.materials)) { + throw invalidProtocolFrame('Invalid Recall passage message materials'); + } + return { + messageId: requireEntityId(record.messageId, 'Recall passage messageId'), + role: record.role as (typeof RECALL_ROLES)[number], + matchKind: requireUtf8String( + record.matchKind, + 'Recall passage message matchKind', + RECALL_QUERY_MATERIAL_NAME_MAX_BYTES, + ), + // A message whose whole content was a pasted file carries no text of its + // own; the material beside it is the point. Empty is legal here, unlike + // the identifiers around it. + text: requireBoundedText( + record.text, + 'Recall passage message text', + RECALL_QUERY_TEXT_MAX_BYTES, + ), + timestamp: requireCount(record.timestamp, 'Recall passage message timestamp'), + isAnchor: record.isAnchor, + ...(record.materials === undefined + ? {} + : { materials: record.materials.map((material) => decodeMaterial(material)) }), + }; +} + +function decodeMaterial(value: unknown): RecallQueryMaterial { + const record = requireShapedRecord( + value, + 'Recall material', + ['name', 'kind', 'mimeType', 'bytes'], + ['resource', 'sourceSessionId', 'materialId'], + ); + if ( + typeof record.kind !== 'string' || + !RECALL_MATERIAL_KINDS.includes(record.kind as (typeof RECALL_MATERIAL_KINDS)[number]) + ) { + throw invalidProtocolFrame('Invalid Recall material kind'); + } + const resource = + record.resource === undefined + ? undefined + : requireUtf8String( + record.resource, + 'Recall material resource', + RECALL_QUERY_RESOURCE_MAX_BYTES, + ); + const sourceSessionId = + record.sourceSessionId === undefined + ? undefined + : requireEntityId(record.sourceSessionId, 'Recall material sourceSessionId'); + const materialId = + record.materialId === undefined + ? undefined + : requireEntityId(record.materialId, 'Recall material materialId'); + // An address says "read this now"; a location says "ask for it". Carrying + // both would offer a caller two answers to one question, so recall never + // emits both and the decoder refuses the shape rather than picking a winner. + if (resource !== undefined && (sourceSessionId !== undefined || materialId !== undefined)) { + throw invalidProtocolFrame('Invalid Recall material location'); + } + if ((sourceSessionId === undefined) !== (materialId === undefined)) { + throw invalidProtocolFrame('Incomplete Recall material location'); + } + return { + name: requireUtf8String( + record.name, + 'Recall material name', + RECALL_QUERY_MATERIAL_NAME_MAX_BYTES, + ), + kind: record.kind as (typeof RECALL_MATERIAL_KINDS)[number], + mimeType: requireUtf8String( + record.mimeType, + 'Recall material mimeType', + RECALL_QUERY_MATERIAL_NAME_MAX_BYTES, + ), + bytes: requireCount(record.bytes, 'Recall material bytes'), + ...(resource === undefined ? {} : { resource }), + ...(sourceSessionId === undefined ? {} : { sourceSessionId }), + ...(materialId === undefined ? {} : { materialId }), + }; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 488f45d631..b45308a83c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -29,7 +29,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import { readLogicalRuntimeExecutionForRun } from '@maka/core/runtime-logical-execution'; -import { foldForMatch } from '@maka/core/thread-search'; +import { foldForMatch } from '@maka/core/transcript-search'; import type { PermissionMode } from '@maka/core/permission'; import { runtimeInvocationOutcome, @@ -54,9 +54,10 @@ import { type BackendPreparationContext, } from '@maka/runtime/session-manager'; import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; -import { buildRecallTools } from '@maka/runtime/recall-tools'; +import { buildRecallTools, type RecallToolDeps } from '@maka/runtime/recall-tools'; import { RECALL_SYNTHETIC_TEXT_PATTERNS } from '@maka/runtime/recall-candidates'; import { createRecallMaterialFetch } from './recall-material-fetch.js'; +import { HostRecallCoordinator } from './recall-coordinator.js'; import { buildBuiltinTools } from '@maka/runtime/builtin-tools'; import { createLocalContinuationSafetyInspector } from '@maka/runtime/continuation-safety'; import { createConfiguredSubagentCatalog } from '@maka/runtime/configured-subagent-catalog'; @@ -691,7 +692,10 @@ export async function createExecutionRuntimeHostComposition( webSearchService.search({ query, limit, ...(abortSignal ? { abortSignal } : {}) }), fetch: (input) => webFetchService.fetch(input), }); - const recallTools = buildRecallTools({ + // The recall surface is built once and shared: the model's tools and the + // Host's `recall.query` operation answer from the same dependency graph, so + // a UI search and a model recall cannot diverge in what they can see. + const recallDeps: RecallToolDeps = { listSessions: () => requireSessionManager(manager).listSessions(), readMessages: async (sessionId, abortSignal) => { if (abortSignal?.aborted) return null; @@ -744,7 +748,9 @@ export async function createExecutionRuntimeHostComposition( incognitoActive: (await runtimePolicyStores.runtimePolicy.getSnapshot()).policy.privacy .incognitoActive, }), - }); + }; + const recallTools = buildRecallTools(recallDeps); + const recall = new HostRecallCoordinator(recallDeps); const childHostTools = [ createHostWebSearchToolFromService(webSearchService), createHostWebFetchToolFromService(webFetchService), @@ -2672,6 +2678,7 @@ export async function createExecutionRuntimeHostComposition( oauth.handlers, externalAgentSetup.handlers, webSearch.handlers, + recall.handlers, networkProxy.handlers, configuration.handlers, ], diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 7c6784ce99..fc22044f06 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -72,6 +72,7 @@ import { SKILL_CATALOG_OPERATION_SPECS } from '../protocol/skill-catalog.js'; import { TURN_OPERATION_SPECS } from '../protocol/turn.js'; import { USAGE_PRICING_OPERATION_SPECS } from '../protocol/usage-pricing.js'; import { WEB_SEARCH_OPERATION_SPECS } from '../protocol/web-search.js'; +import { RECALL_OPERATION_SPECS } from '../protocol/recall.js'; import { WORKHUB_COORDINATION_OPERATION_SPECS } from '../protocol/workhub-coordination.js'; import { PLUGIN_PLATFORM_OPERATION_SPECS } from '../protocol/plugin-platform.js'; import { boundedFailureDiagnostic } from './failure-diagnostic.js'; @@ -227,6 +228,8 @@ export type ProjectCatalogOperationHandlerMap = Pick< export type DeepResearchOperationHandlerMap = Pick; export type DailyReviewOperationHandlerMap = Pick; export type WebSearchOperationHandlerMap = Pick; +export type RecallOperationKey = keyof typeof RECALL_OPERATION_SPECS; +export type RecallOperationHandlerMap = Pick; export type NetworkProxyOperationHandlerMap = Pick; export type ConfigurationOperationHandlerMap = Pick; export type WorkHubCoordinationOperationHandlerMap = Pick< diff --git a/packages/runtime-host/src/server/recall-coordinator.ts b/packages/runtime-host/src/server/recall-coordinator.ts new file mode 100644 index 0000000000..0923177f9f --- /dev/null +++ b/packages/runtime-host/src/server/recall-coordinator.ts @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { runRecall, type RecallDeps, type RecallPassage } from '@maka/core/recall'; +import type { + OperationOutcome, + RecallQueryInput, + RecallQueryPassage, + RecallQueryResult, +} from '../protocol/index.js'; +import type { RecallOperationHandlerMap } from './operation-dispatcher.js'; + +/** + * Serves `recall.query` from the same dependency graph the model's `Recall` + * tool uses. + * + * The point of this coordinator is that it owns no retrieval logic: it hands + * the request to `runRecall` and projects the answer. Whatever recall can see — + * privacy gating, archived Sessions, the distilled-fact store — a Client + * asking over the wire sees the same thing, because there is one implementation + * and this is not a second one. + * + * Unlike the model's tool, no turn is excluded. A tool call runs inside a turn + * and must not surface that turn's own text back as corroboration; a Client + * search is not inside any turn, so there is nothing to exclude. + */ +export class HostRecallCoordinator { + readonly handlers: RecallOperationHandlerMap = { + 'recall.query': (input) => this.#query(input), + }; + + constructor(private readonly deps: RecallDeps) {} + + async #query(input: RecallQueryInput): Promise> { + try { + const result = await runRecall(input, this.deps, { includeArchived: true }); + return { ok: true, result: projectRecallResult(result) }; + } catch { + return { + ok: false, + error: { code: 'internal_failure', message: 'Recall query failed' }, + }; + } + } +} + +/** + * `RecallQueryResult` is the core result with the Host's field vocabulary. + * + * Recall already returns exactly these fields; the projection exists to drop + * core-internal detail and to make the boundary explicit, so a change to the + * core shape shows up here rather than silently widening the wire. + */ +export function projectRecallResult( + result: Awaited>, +): RecallQueryResult { + if (!result.ok) { + return { ok: false, reason: result.reason, message: result.message }; + } + return { + ok: true, + facts: result.facts.map((fact) => ({ + content: fact.content, + kind: fact.kind, + observedAt: fact.observedAt, + })), + passages: result.passages.map((passage) => projectPassage(passage)), + gaps: result.gaps, + searchedEverySession: result.scannedFully, + }; +} + +function projectPassage(passage: RecallPassage): RecallQueryPassage { + return { + sessionId: passage.sessionId, + sessionTitle: passage.sessionTitle, + ...(passage.turnId ? { turnId: passage.turnId } : {}), + anchorMessageId: passage.anchorMessageId, + sequence: passage.sequence, + messages: passage.messages.map((message) => ({ + messageId: message.messageId, + role: message.role, + matchKind: message.matchKind, + text: message.text, + timestamp: message.timestamp, + isAnchor: message.isAnchor, + ...(message.materials + ? { + materials: message.materials.map((material) => ({ + name: material.name, + kind: material.kind, + mimeType: material.mimeType, + bytes: material.bytes, + ...(material.resource ? { resource: material.resource } : {}), + ...(material.sourceSessionId && material.materialId + ? { + sourceSessionId: material.sourceSessionId, + materialId: material.materialId, + } + : {}), + })), + } + : {}), + })), + matchedTerms: passage.matchedTerms, + score: passage.score, + ...(passage.lastMessageAt !== undefined ? { lastMessageAt: passage.lastMessageAt } : {}), + hasMoreBefore: passage.hasMoreBefore, + hasMoreAfter: passage.hasMoreAfter, + ...(passage.truncated ? { truncated: true } : {}), + }; +} diff --git a/packages/runtime/src/recall-tools.ts b/packages/runtime/src/recall-tools.ts index 3fd5a35311..fa43b67c41 100644 --- a/packages/runtime/src/recall-tools.ts +++ b/packages/runtime/src/recall-tools.ts @@ -267,6 +267,10 @@ function projectPassage(passage: RecallPassage, activeSessionId: string) { title: passage.sessionTitle, ...(passage.turnId ? { turn_id: passage.turnId } : {}), anchor_message_id: passage.anchorMessageId, + // The anchor's index in its Session transcript. Carried for the same + // reason a UI needs it: a caller that wants to point at the passage in a + // transcript scrolls by sequence, and this spares it a second lookup. + sequence: passage.sequence, is_current_session: passage.sessionId === activeSessionId, ...(passage.lastMessageAt !== undefined ? { last_message_at: passage.lastMessageAt } : {}), matched_terms: passage.matchedTerms, diff --git a/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx b/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx index 64ab6970f7..a1a7da1d2f 100644 --- a/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx +++ b/packages/ui/src/__tests__/search-modal-lifecycle.test.tsx @@ -22,7 +22,7 @@ import test from 'node:test'; import { act, createElement } from 'react'; import { parseHTML } from 'linkedom'; import { deferred } from '@maka/core/test-only/async-primitives'; -import type { SearchRequest, SearchResult } from '@maka/core/search'; +import type { RecallSearchOutcome, RecallSearchRequest } from '../search-modal.js'; // Exercise the real palette's async transitions: source-only tests cannot see // a canceled request retaining its optimistic input and loading indicator. @@ -50,18 +50,18 @@ test('dismissed and superseded searches stop holding the palette busy', async () const { createRoot } = await import('react-dom/client'); const { SearchModal } = await import('../search-modal.js'); const { LocaleProvider } = await import('../locale-context.js'); - const requests: ReturnType>[] = []; + const requests: ReturnType>[] = []; const requestIds: string[] = []; const cancelled: string[] = []; const deps = { - searchThread: (_request: SearchRequest, requestId?: string) => { + searchRecall: (_request: RecallSearchRequest, requestId?: string) => { assert.ok(requestId); requestIds.push(requestId); - const request = deferred(); + const request = deferred(); requests.push(request); return request.promise; }, - cancelThread: async (requestId: string) => { cancelled.push(requestId); }, + cancelRecall: async (requestId: string) => { cancelled.push(requestId); }, }; const root = createRoot(document.getElementById('root')!); let isOpen = true; @@ -96,15 +96,35 @@ test('dismissed and superseded searches stop holding the palette busy', async () await type('older'); await type('maka'); assert.equal(requests.length, 3); - await act(async () => { requests[2]!.resolve([ - { source: 'thread', title: 'Latest maka match', target: { kind: 'thread', sessionId: 'latest' } }, - ]); }); + await act(async () => { requests[2]!.resolve({ + passages: [{ + sessionId: 'latest', + sessionTitle: 'Latest maka match', + anchorMessageId: 'latest-anchor', + sequence: 0, + messages: [{ + messageId: 'latest-anchor', + role: 'assistant', + matchKind: 'assistant_message', + text: 'Latest maka match', + timestamp: 1, + isAnchor: true, + }], + matchedTerms: ['maka'], + score: 1, + }], + gaps: '', + searchedEverySession: true, + }); }); assert.match(document.body.textContent ?? '', /Latest maka match/); assert.equal(busy(), 0, 'completed results must not wait for the superseded request'); assert.equal(input().value, 'maka'); assert.deepEqual(cancelled, [requestIds[0], requestIds[1]]); - await act(async () => { requests[0]!.resolve([]); requests[1]!.resolve([]); }); + await act(async () => { + requests[0]!.resolve({ passages: [], gaps: '', searchedEverySession: true }); + requests[1]!.resolve({ passages: [], gaps: '', searchedEverySession: true }); + }); assert.match(document.body.textContent ?? '', /Latest maka match/); assert.equal(busy(), 0); diff --git a/packages/ui/src/__tests__/search-modal-recall.test.ts b/packages/ui/src/__tests__/search-modal-recall.test.ts new file mode 100644 index 0000000000..57e8abb9f4 --- /dev/null +++ b/packages/ui/src/__tests__/search-modal-recall.test.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { passageSnippet, recallTermsFor, type RecallSearchPassage } from '../search-modal.js'; + +function passage( + messages: RecallSearchPassage['messages'], +): RecallSearchPassage { + return { + sessionId: 's1', + sessionTitle: 'title', + anchorMessageId: 'a', + sequence: 0, + messages, + matchedTerms: [], + score: 1, + }; +} + +function message(text: string, isAnchor: boolean): RecallSearchPassage['messages'][number] { + return { + messageId: `m-${text}`, + role: 'user', + matchKind: 'user_message', + text, + timestamp: 1, + isAnchor, + }; +} + +test('a typed phrase becomes the distinct terms recall matches', () => { + // Recall matches literal terms, OR-combined, so a sentence is most useful as + // its words. This is the user-visible behavior change of the recall lane. + assert.deepEqual(recallTermsFor('deploy script'), ['deploy', 'script']); + assert.deepEqual(recallTermsFor(' 部署 脚本 '), ['部署', '脚本']); + // A repeated word is one term, not a repeated vote for it. + assert.deepEqual(recallTermsFor('deploy deploy'), ['deploy']); + assert.deepEqual(recallTermsFor('single'), ['single']); +}); + +test('an empty or whitespace query produces no terms', () => { + assert.deepEqual(recallTermsFor(''), []); + assert.deepEqual(recallTermsFor(' '), []); +}); + +test('terms are capped so a pasted paragraph cannot become an unbounded query', () => { + const many = Array.from({ length: 40 }, (_unused, index) => `w${index}`).join(' '); + assert.equal(recallTermsFor(many).length, 8); +}); + +test('a passage snippet is the anchor text, which is what recall matched on', () => { + assert.equal( + passageSnippet(passage([message('context', false), message('the answer', true)])), + 'the answer', + ); +}); + +test('a file-only anchor falls back to the first text the passage carries', () => { + // A message whose whole content was a pasted file has no text of its own; + // showing nothing would hide the hit the user searched for. + assert.equal( + passageSnippet(passage([message('', true), message('surrounding words', false)])), + 'surrounding words', + ); + assert.equal(passageSnippet(passage([message('', true)])), ''); +}); diff --git a/packages/ui/src/__tests__/search-modal-source.test.ts b/packages/ui/src/__tests__/search-modal-source.test.ts deleted file mode 100644 index 64281c9e08..0000000000 --- a/packages/ui/src/__tests__/search-modal-source.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { deferred } from '@maka/core/test-only/async-primitives'; -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import type { SearchErrorReason, SearchResult } from '@maka/core/search'; -import { runThreadSearch } from '@maka/core/thread-search'; -import { UI_LOCALES, type UiCatalog } from '@maka/core/ui-locale'; -import { createThreadSearchSource, searchErrorText } from '../search-modal.js'; -import { getShellControlsCopy } from '../shell-controls-copy.js'; -function result(sessionId: string): SearchResult { - return { - source: 'thread', - title: sessionId, - target: { kind: 'thread', sessionId }, - }; -} - -function createHarness() { - const requests = new Map< - string, - ReturnType< - typeof deferred< - SearchResult[] | { - ok: false; - reason: 'provider_error'; - message: string; - } - > - > - >(); - let visibleItemIds: string[] = []; - let visibleError: string | null = null; - const source = createThreadSearchSource({ - searchThread: ({ query }) => { - const request = deferred< - SearchResult[] | { - ok: false; - reason: 'provider_error'; - message: string; - } - >(); - requests.set(query, request); - return request.promise; - }, - canNavigate: true, - resultsLabel: 'Results', - onQueryChange: () => {}, - onErrorChange: (error) => { - visibleError = error?.reason ?? null; - }, - onItemsChange: (items) => { - visibleItemIds = items.map((item) => item.id); - }, - }); - return { - source, - requests, - getVisibleItemIds: () => visibleItemIds, - getVisibleError: () => visibleError, - }; -} - -describe('thread search source', () => { - it('keeps the selectable mapping while a filtered follow-up is pending', async () => { - const harness = createHarness(); - const initial = harness.source.search('current'); - harness.requests.get('current')?.resolve([result('current-session')]); - await initial; - - harness.source.cancel?.(); - void harness.source.search('current-session'); - - assert.deepEqual(harness.getVisibleItemIds(), ['current-session::0']); - }); - - it('does not let an older success replace the current item mapping', async () => { - const harness = createHarness(); - const older = harness.source.search('older'); - harness.source.cancel?.(); - const current = harness.source.search('current'); - - harness.requests.get('current')?.resolve([result('current-session')]); - await current; - assert.deepEqual(harness.getVisibleItemIds(), ['current-session::0']); - - harness.requests.get('older')?.resolve([result('older-session')]); - await older; - assert.deepEqual(harness.getVisibleItemIds(), ['current-session::0']); - }); - - it('does not let an older error replace the current successful state', async () => { - const harness = createHarness(); - const older = harness.source.search('older'); - harness.source.cancel?.(); - const current = harness.source.search('current'); - - harness.requests.get('current')?.resolve([result('current-session')]); - await current; - assert.equal(harness.getVisibleError(), null); - - harness.requests.get('older')?.resolve({ - ok: false, - reason: 'provider_error', - message: 'stale failure', - }); - await older; - assert.equal(harness.getVisibleError(), null); - assert.deepEqual(harness.getVisibleItemIds(), ['current-session::0']); - }); -}); - -describe('search error copy', () => { - it('maps the reasons thread search emits per locale and falls back for the rest', () => { - const zh = getShellControlsCopy('zh-CN').search; - const en = getShellControlsCopy('en').search; - const mapped = ['incognito_active', 'invalid_query', 'aborted', 'disabled', 'provider_error']; - assert.deepEqual(Object.keys(zh.errorByReason).sort(), [...mapped].sort()); - assert.deepEqual(Object.keys(en.errorByReason).sort(), [...mapped].sort()); - assert.equal(searchErrorText('incognito_active', zh), '关闭隐私模式后可以继续按关键词查找历史任务。'); - assert.equal(searchErrorText('invalid_query', zh), '搜索词无效,请缩短内容或移除凭据后重试。'); - assert.equal(searchErrorText('disabled', zh), '搜索当前不可用。'); - assert.equal(searchErrorText('aborted', en), 'Search was canceled.'); - assert.equal(searchErrorText('provider_error', en), 'Search failed. Try again.'); - assert.equal(searchErrorText('timeout', en), 'Search needs to be refreshed. Try again.'); - assert.equal(searchErrorText('timeout', zh), '搜索服务需要刷新,请重试。'); - assert.equal( - searchErrorText('constructor' as SearchErrorReason, en), - 'Search needs to be refreshed. Try again.', - ); - }); - - it('presents both overlong and credential queries without assuming the rejection cause', async (context) => { - context.mock.method(console, 'error', () => {}); - const expected = { - 'zh-CN': '搜索词无效,请缩短内容或移除凭据后重试。', - 'zh-TW': '搜尋詞無效,請縮短內容或移除憑證後重試。', - en: 'Invalid search query. Shorten it or remove credential material and try again.', - } satisfies UiCatalog; - for (const locale of UI_LOCALES) { - for (const query of ['a'.repeat(501), 'password=supersecret']) { - let visibleError: string | undefined; - const source = createThreadSearchSource({ - searchThread: async (request) => { - const fail = async (): Promise => assert.fail('Rejected queries must not read history'); - const response = await runThreadSearch(request, { - listSessions: fail, - readMessages: fail, - getPrivacyContext: fail, - }); - assert.equal(response.ok, false); - if (response.ok) assert.fail('Expected invalid query'); - assert.equal(response.reason, 'invalid_query'); - return response; - }, - canNavigate: true, - resultsLabel: 'Results', - onQueryChange: () => {}, - onItemsChange: (items) => assert.deepEqual(items, []), - onErrorChange: (error) => { - if (error) visibleError = searchErrorText(error.reason, getShellControlsCopy(locale).search); - }, - }); - await source.search(query); - assert.equal(visibleError, expected[locale], `${locale}: ${query.length} characters`); - } - } - }); -}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index b1a5341919..e3bd7cbda4 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -27,6 +27,12 @@ export { CapabilityAuditStrip } from './capability-audit-strip.js'; export { ModuleHubSelector } from './module-hub-selector.js'; export type { ModuleHubHeader } from './module-hub-selector.js'; export { SearchModal } from './search-modal.js'; +export type { + RecallSearchFailure, + RecallSearchOutcome, + RecallSearchPassage, + RecallSearchRequest, +} from './search-modal.js'; export { SessionListPanel } from './session-list-panel.js'; export { SessionRailProvider } from './session-rail-context.js'; export type { diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index 7324253d8e..f8d3bb9096 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -18,7 +18,6 @@ */ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; import { CommandPalette as AstryxCommandPalette, CommandPaletteFooter, @@ -31,38 +30,96 @@ import { lookupCopy } from '@maka/core/ui-locale'; import { getShellControlsCopy } from './shell-controls-copy.js'; import { useUiLocale } from './locale-context.js'; +/** + * One passage recall returned, as the modal renders it. + * + * Recall answers with ranked passages that already carry their surrounding + * exchange, so a result is a piece of a conversation rather than a single + * line. `sequence` is the anchor's index in its Session transcript, which is + * what navigation scrolls to. + */ +export interface RecallSearchPassage { + readonly sessionId: string; + readonly sessionTitle: string; + readonly turnId?: string; + readonly anchorMessageId: string; + readonly sequence: number; + readonly messages: readonly { + readonly messageId: string; + readonly role: 'user' | 'assistant' | 'tool'; + readonly matchKind: string; + readonly text: string; + readonly timestamp: number; + readonly isAnchor: boolean; + }[]; + readonly matchedTerms: readonly string[]; + readonly score: number; + readonly lastMessageAt?: number; +} + +export interface RecallSearchRequest { + readonly terms: readonly string[]; + readonly limit?: number; +} + +export interface RecallSearchOutcome { + readonly passages: readonly RecallSearchPassage[]; + readonly gaps: string; + readonly searchedEverySession: boolean; +} + +export interface RecallSearchFailure { + readonly ok: false; + readonly reason: string; + readonly message: string; +} + interface SearchModalDeps { - searchThread( - request: SearchRequest, + searchRecall( + request: RecallSearchRequest, requestId?: string, - ): Promise< - SearchResult[] | { - ok: false; - reason: SearchErrorReason; - message: string; - } - >; - cancelThread?(requestId: string): Promise; + ): Promise; + cancelRecall?(requestId: string): Promise; } interface SearchItemAuxiliaryData { - result: SearchResult; + passage: RecallSearchPassage; } type SearchItem = SearchableItem; -interface ThreadSearchSourceInput { - searchThread?: SearchModalDeps['searchThread']; - cancelThread?: SearchModalDeps['cancelThread']; +interface RecallSearchSourceInput { + searchRecall?: SearchModalDeps['searchRecall']; + cancelRecall?: SearchModalDeps['cancelRecall']; canNavigate: boolean; resultsLabel: string; onQueryChange(query: string): void; - onErrorChange(error: { reason: SearchErrorReason } | null): void; + onErrorChange(error: { reason: string } | null): void; onItemsChange(items: SearchItem[]): void; } -export function createThreadSearchSource( - input: ThreadSearchSourceInput, +/** + * Turns a phrase into the literal terms recall matches. + * + * Recall matches case-insensitive substrings, OR-combined, and ranks a passage + * higher when it contains more of them — so a sentence works best as its + * distinct words rather than as one string. Splitting on whitespace keeps the + * user's typed phrase intact as a query while giving recall terms it can act + * on; a single word is passed through unchanged. + */ +export function recallTermsFor(query: string): string[] { + const trimmed = query.trim(); + if (!trimmed) return []; + const words = trimmed.split(/\s+/u).filter((word) => word.length > 0); + const unique: string[] = []; + for (const word of words) { + if (!unique.includes(word)) unique.push(word); + } + return unique.slice(0, 8); +} + +export function createRecallSearchSource( + input: RecallSearchSourceInput, ): SearchSource { let generation = 0; let cancelPending: (() => void) | undefined; @@ -80,7 +137,8 @@ export function createThreadSearchSource( const requestGeneration = generation; const trimmed = query.trim(); input.onQueryChange(trimmed); - if (!trimmed || !input.searchThread) { + const terms = recallTermsFor(trimmed); + if (terms.length === 0 || !input.searchRecall) { input.onErrorChange(null); input.onItemsChange([]); return []; @@ -91,41 +149,43 @@ export function createThreadSearchSource( // React's palette transition must finish even if the Host is slow // or disconnected. Ignoring its eventual result alone leaves it busy. resolve(undefined); - void input.cancelThread?.(requestId).catch((error) => { + void input.cancelRecall?.(requestId).catch((error) => { console.error('[search] cancellation failed', error); }); }; }); try { const response = await Promise.race([ - input.searchThread({ source: 'thread', query: trimmed, limit: 10 }, requestId), + input.searchRecall({ terms, limit: 10 }, requestId), cancelled, ]); if (generation !== requestGeneration || response === undefined) return []; - if (!Array.isArray(response)) { - console.error('[search] thread search failed', response); - input.onErrorChange({ reason: response.reason }); + if (!Array.isArray((response as RecallSearchOutcome).passages)) { + console.error('[search] recall search failed', response); + input.onErrorChange({ reason: (response as RecallSearchFailure).reason }); input.onItemsChange([]); return []; } input.onErrorChange(null); - const items = response.flatMap((result, index) => { - if (!input.canNavigate || result.target?.kind !== 'thread') { - return []; - } - return [ - { - id: `${result.target.sessionId}:${result.target.turnId ?? ''}:${index}`, - label: result.title ?? result.summary ?? input.resultsLabel, - auxiliaryData: { result }, - }, - ]; - }); + const items = (response as RecallSearchOutcome).passages.flatMap( + (passage, index) => { + if (!input.canNavigate) return []; + return [ + { + // Two Hosts can name the same Session id, so the index and the + // anchor id are part of the identity, not decoration. + id: `${passage.sessionId}:${passage.anchorMessageId}:${index}`, + label: passage.sessionTitle || input.resultsLabel, + auxiliaryData: { passage }, + }, + ]; + }, + ); input.onItemsChange(items); return items; } catch (caught) { if (generation !== requestGeneration) return []; - console.error('[search] thread search failed', caught); + console.error('[search] recall search failed', caught); input.onErrorChange({ reason: 'provider_error' }); input.onItemsChange([]); return []; @@ -137,14 +197,28 @@ export function createThreadSearchSource( } export function searchErrorText( - reason: SearchErrorReason, + reason: string, copy: ReturnType['search'], ): string { return lookupCopy(copy.errorByReason, reason) ?? copy.errorFallback; } /** - * Thread search is an asynchronous result picker. Astryx CommandPalette owns + * A passage's anchor text, used as the result's snippet. The anchor is the + * message recall matched on, so it is the line that explains the hit; falling + * back to the first non-empty message keeps a file-only anchor visible. + */ +export function passageSnippet(passage: RecallSearchPassage): string { + const anchor = passage.messages.find((message) => message.isAnchor); + if (anchor && anchor.text.trim().length > 0) return anchor.text; + for (const message of passage.messages) { + if (message.text.trim().length > 0) return message.text; + } + return ''; +} + +/** + * Recall search is an asynchronous result picker. Astryx CommandPalette owns * the dialog, search input, listbox, keyboard navigation, focus, and * dismissal. Maka only adapts the product search boundary and renders result * content. @@ -163,7 +237,7 @@ export function SearchModal(props: { }), [copy.resultsLabel], ); - const [error, setError] = useState<{ reason: SearchErrorReason } | null>(null); + const [error, setError] = useState<{ reason: string } | null>(null); const [activeQuery, setActiveQuery] = useState(''); const itemByIdRef = useRef(new Map()); const pendingNavigationRef = useRef<{ @@ -189,9 +263,9 @@ export function SearchModal(props: { const searchSource = useMemo>( () => - createThreadSearchSource({ - searchThread: props.deps?.searchThread, - cancelThread: props.deps?.cancelThread, + createRecallSearchSource({ + searchRecall: props.deps?.searchRecall, + cancelRecall: props.deps?.cancelRecall, canNavigate: Boolean(props.onNavigateToSession), resultsLabel: copy.resultsLabel, onQueryChange: setActiveQuery, @@ -224,47 +298,47 @@ export function SearchModal(props: { width={560} maxHeight="64vh" data-maka-contract="search-modal" - input={( + input={ - )} - footer={( + } + footer={ {copy.resultsLabel} - )} + } emptyBootstrapText={ - props.deps?.searchThread ? copy.introduction : copy.unavailable + props.deps?.searchRecall ? copy.introduction : copy.unavailable } emptySearchText={emptySearchText} onValueChange={(itemId) => { - const result = - itemByIdRef.current.get(itemId)?.auxiliaryData?.result; - if (result?.target?.kind !== 'thread') return; + const passage = + itemByIdRef.current.get(itemId)?.auxiliaryData?.passage; + if (!passage) return; pendingNavigationRef.current = { - sessionId: result.target.sessionId, - turnId: result.target.turnId, - sequence: result.target.sequence, + sessionId: passage.sessionId, + ...(passage.turnId ? { turnId: passage.turnId } : {}), + sequence: passage.sequence, }; }} renderItem={(item) => { - const result = item.auxiliaryData?.result; - if (!result) return item.label; + const passage = item.auxiliaryData?.passage; + if (!passage) return item.label; + const snippet = passageSnippet(passage); return (
- {result.title} + {passage.sessionTitle || item.label}
- {result.summary && ( -
- {result.summary} -
- )} - {result.snippet && ( +
+ {passage.messages.find((message) => message.isAnchor)?.matchKind ?? + ''} +
+ {snippet && (
- {renderSearchSnippet(result.snippet, activeQuery)} + {renderSearchSnippet(snippet, activeQuery)}
)}
diff --git a/packages/ui/src/shell-controls-copy.ts b/packages/ui/src/shell-controls-copy.ts index 0fa9a18702..3223641365 100644 --- a/packages/ui/src/shell-controls-copy.ts +++ b/packages/ui/src/shell-controls-copy.ts @@ -17,12 +17,18 @@ * under the License. */ -import type { SearchErrorReason } from '@maka/core/search'; +import type { RecallFailureReason } from '@maka/core/recall'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; -export type ThreadSearchErrorReason = Extract< - SearchErrorReason, - 'incognito_active' | 'invalid_query' | 'aborted' | 'disabled' | 'provider_error' +/** + * The failures the Search modal can show. Recall's own vocabulary, because + * recall is what runs: a search is refused for reasons a generic search + * contract has no notion of, and widening a shared union would push them onto + * every other search surface. + */ +export type RecallSearchErrorReason = Extract< + RecallFailureReason, + 'incognito_active' | 'invalid_query' | 'aborted' >; type ShellControlsCopy = { @@ -44,7 +50,7 @@ type ShellControlsCopy = { conversationsLabel: string; placeholder: string; unavailable: string; - errorByReason: Record; + errorByReason: Record; errorFallback: string; introduction: string; empty: string; @@ -74,7 +80,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { incognito_active: '关闭隐私模式后可以继续按关键词查找历史任务。', invalid_query: '搜索词无效,请缩短内容或移除凭据后重试。', aborted: '搜索已取消。', - disabled: '搜索当前不可用。', + not_found: '没有找到匹配的历史任务。换个关键词试试。', provider_error: '搜索服务出错,请重试。', }, errorFallback: '搜索服务需要刷新,请重试。', @@ -104,7 +110,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { incognito_active: '關閉隱私模式後可以繼續按關鍵詞查詢歷史任務。', invalid_query: '搜尋詞無效,請縮短內容或移除憑證後重試。', aborted: '搜尋已取消。', - disabled: '搜尋目前無法使用。', + not_found: '沒有找到符合的歷史任務。換個關鍵詞試試。', provider_error: '搜尋服務發生錯誤,請重試。', }, errorFallback: '搜尋服務需要重新整理,請重試。', @@ -134,7 +140,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { incognito_active: 'Turn off privacy mode to search previous tasks by keyword.', invalid_query: 'Invalid search query. Shorten it or remove credential material and try again.', aborted: 'Search was canceled.', - disabled: 'Search is unavailable right now.', + not_found: 'No matching previous task was found. Try another keyword.', provider_error: 'Search failed. Try again.', }, errorFallback: 'Search needs to be refreshed. Try again.',