From 4af528c6585377e2491f6e4076d9aeaa45e3e7a9 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Sun, 20 Sep 2026 12:15:23 +0800 Subject: [PATCH 1/2] fix(desktop): stop history search after enough matches Read transcript pages in search order and stop once the result budget is met, retaining cancellation, truncation and read-failure behavior. Fixes #5523 Refs #2913, #4677 Generated-by: Codex --- .../runtime-host-search-ipc-main.test.ts | 40 +- .../thread-search-pagination.test.ts | 262 ++++++++ .../src/main/__tests__/thread-search.test.ts | 64 +- .../src/main/runtime-host-search-ipc-main.ts | 79 ++- ...story-search-diagnosis-2026-09-20.zh-CN.md | 143 +++++ .../build.mjs | 113 ++++ .../implementation-manifest.json | 17 + .../implementation-results.json | 586 ++++++++++++++++++ .../manifest.json | 17 + .../probe.mjs | 415 +++++++++++++ .../results.json | 586 ++++++++++++++++++ packages/core/src/thread-search.ts | 155 +++-- .../src/__tests__/search-modal-source.test.ts | 2 +- 13 files changed, 2379 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/thread-search-pagination.test.ts create mode 100644 docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md create mode 100644 docs/performance/history-search-diagnosis-2026-09-20/build.mjs create mode 100644 docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json create mode 100644 docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json create mode 100644 docs/performance/history-search-diagnosis-2026-09-20/manifest.json create mode 100644 docs/performance/history-search-diagnosis-2026-09-20/probe.mjs create mode 100644 docs/performance/history-search-diagnosis-2026-09-20/results.json 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 index 714ead4d8c..629bcb18aa 100644 --- 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 @@ -26,7 +26,7 @@ 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 type { DesktopRuntimeHostClient, DesktopRuntimeHostSession } 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'; @@ -60,9 +60,9 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as // 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 () => [ + ...transcriptPages(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: 'assistant', id: 'host-reply-0', turnId: 'turn-host-0', ts: 2, text: '回答 0', modelId: 'fixture' }, { type: 'user', id: 'host-user-1', turnId: 'turn-host-1', ts: 3, text: '第 1 个问题' }, { type: 'user', @@ -71,7 +71,7 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as ts: 4, text: '第 3 个问题:这一段的调用链路是怎样的?', }, - ], + ]), close: async () => { closed += 1; }, @@ -159,7 +159,7 @@ test('canceling a search closes its transcript and stops reading further session openSession: async (id) => { opened.push(id); return { - loadTranscript: () => { firstRead.resolve(); return transcript.promise; }, + ...transcriptPages(() => { firstRead.resolve(); return transcript.promise; }), close: async () => { closed += 1; transcript.resolve([]); }, } as never; }, @@ -201,7 +201,7 @@ test('a canceled search is not replayed on a replacement Host candidate', async openSession: async () => { opened += 1; return { - loadTranscript: () => { started.resolve(); return transcript.promise; }, + ...transcriptPages(() => { started.resolve(); return transcript.promise; }), close: async () => {}, } as never; }, @@ -257,7 +257,7 @@ for (const lifecycleEvent of ['destroyed', 'render-process-gone'] as const) { await started.promise; sender.emit(lifecycleEvent, {}, { reason: 'crashed', exitCode: 1 }); opening.resolve({ - loadTranscript: async () => { read += 1; return []; }, + ...transcriptPages(async () => { read += 1; return []; }), close: async () => { closed += 1; }, } as never); assert.equal((await task).reason, 'aborted'); @@ -286,10 +286,10 @@ test('renderer crash closes an in-flight search and allows a new search on the s opened.push(id); const abandoned = opened.length === 1; return { - loadTranscript: async () => { + ...transcriptPages(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; }, @@ -339,13 +339,13 @@ test('rapid replacement and dismissal stop each old scan while the latest query const scan = { closed: 0, page: deferred() }; scans.push(scan); return { - loadTranscript: async () => { + ...transcriptPages(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; }, @@ -451,3 +451,21 @@ function catalogSession(id: string, name: string): SessionCatalogProjection { orchestrationMode: 'default', }; } + +/** Lifecycle tests can hold a page in flight without opening a real Host. */ +function transcriptPages(read: () => Promise): Pick< + DesktopRuntimeHostSession, 'transcriptBootstrap' | 'decodeTranscriptPage' +> { + return { + transcriptBootstrap: { + durable: { + kind: 'page', sessionId: 'fixture', direction: 'older', throughSequence: null, + fragments: [], rawBytes: 0, nextCursor: null, endsAtTurnBoundary: true, + }, + }, + decodeTranscriptPage: async () => ({ + messages: (await read()).map((message, identity) => ({ identity, message })), + nextCursor: null, + }), + }; +} diff --git a/apps/desktop/src/main/__tests__/thread-search-pagination.test.ts b/apps/desktop/src/main/__tests__/thread-search-pagination.test.ts new file mode 100644 index 0000000000..2106451afa --- /dev/null +++ b/apps/desktop/src/main/__tests__/thread-search-pagination.test.ts @@ -0,0 +1,262 @@ +/* + * 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 { EventEmitter } from 'node:events'; +import test from 'node:test'; +import { markPersisted } from '@maka/core/persisted-value'; +import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; +import type { SearchError, SearchRequest, SearchResult } from '@maka/core/search'; +import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionCatalogProjection, + type SessionTranscriptPageInput, +} from '@maka/runtime-host/protocol'; +import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; +import { + createSessionTranscriptBootstrap, + readSessionTranscriptPage, + updateSubscriberTranscriptHighWater, +} from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; +import { transcriptReader } from '../../../../../packages/runtime-host/dist/__tests__/fixtures/session-transcript-reader.js'; +import type { IpcHandler } from '../ipc-reconnect-policy.js'; +import { registerRuntimeHostSearchIpc } from '../runtime-host-search-ipc-main.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; + +test('stops reading history after the oldest message satisfies the result limit', async () => { + const fixture = searchFixture(messages(20_000, [0])); + const hits = await fixture.search(1); + assert.equal(hits.length, 1); + assert.equal(hits[0]!.target?.turnId, 'turn-0'); + assert.equal(hits[0]!.truncated, true); + assert.equal(fixture.counts.closed, 1); + assert.equal(fixture.counts.decoded, 256, 'a result in the first page must not decode the rest of history'); + assert.equal(fixture.requests.length, 1, 'the result budget must stop transcript pagination'); + assert.equal(fixture.requests[0]!.direction, 'newer'); + assert.equal(fixture.requests[0]!.anchorSequence, null); +}); + +test('a match at the end of a page preserves truncation without reading another page', async () => { + const fixture = searchFixture(messages(600, [255, 256])); + const hits = await fixture.search(1); + assert.deepEqual(hits.map((hit) => hit.target?.turnId), ['turn-255']); + assert.equal(hits[0]!.truncated, true); + assert.equal(fixture.requests.length, 1); + assert.equal(fixture.counts.decoded, 256); + assert.equal(fixture.counts.closed, 1); +}); + +test('keeps the first ten matches in transcript order across pages with sparse identities', async () => { + const matching = Array.from({ length: 10 }, (_, index) => 250 + index); + const fixture = searchFixture(messages(600, matching), { sequenceStride: 8 }); + const hits = await fixture.search(10); + assert.deepEqual(hits.map((hit) => hit.target?.turnId), matching.map((index) => `turn-${index}`)); + assert.deepEqual(hits.map((hit) => hit.target?.sequence), matching.map((index) => index * 8)); + assert.equal(hits[9]!.truncated, true); + assert.equal(fixture.requests.length, 2); + assert.ok(fixture.requests.every((request) => request.direction === 'newer')); + assert.equal(fixture.counts.decoded, 512); +}); + +test('uses a complete small bootstrap and marks a last-message match as complete', async () => { + const fixture = searchFixture(messages(2, [1]), { sequenceStride: 8 }); + const hits = await fixture.search(1); + assert.equal(hits[0]!.target?.turnId, 'turn-1'); + assert.equal(hits[0]!.target?.sequence, 8); + assert.equal(hits[0]!.truncated, undefined); + assert.equal(fixture.requests.length, 0); + assert.equal(fixture.counts.decoded, 2); + assert.equal(fixture.counts.closed, 1); +}); + +test('empty history closes the reader and a title match never opens it', async () => { + const empty = searchFixture([]); + assert.deepEqual(await empty.search(1), []); + assert.equal(empty.counts.closed, 1); + const title = searchFixture(messages(600, [0]), { title: 'needleunique title' }); + const hits = await title.search(1); + assert.equal(hits[0]!.summary, '任务标题'); + assert.equal(title.counts.opened, 0); +}); + +for (const matching of [[], [599], [1]] as const) { + test(`scans all remaining pages when matches are insufficient: ${JSON.stringify(matching)}`, async () => { + const fixture = searchFixture(messages(600, matching)); + const hits = await fixture.search(10); + assert.deepEqual(hits.map((hit) => hit.target?.turnId), matching.map((index) => `turn-${index}`)); + assert.ok(hits.every((hit) => hit.truncated === undefined)); + assert.equal(fixture.requests.length, 3); + assert.equal(fixture.counts.decoded, 600); + assert.equal(fixture.counts.closed, 1); + }); +} + +test('finishes a fragmented message before matching and stops before the following page', async () => { + const durable = messages(600, []); + durable[0] = { type: 'user', id: 'message-0', turnId: 'turn-0', ts: 1, + text: `${'x'.repeat(600 * 1024)}needleunique` }; + const fixture = searchFixture(durable); + const hits = await fixture.search(1); + assert.equal(hits[0]!.target?.turnId, 'turn-0'); + assert.equal(hits[0]!.truncated, true); + assert.equal(fixture.counts.decoded, 1); + assert.equal(fixture.requests.length, 2, 'the second request completes the first message only'); + assert.equal(fixture.counts.closed, 1); +}); + +test('cancellation stops pagination after an in-flight page and closes exactly once', async () => { + const started = deferred(); + const release = deferred(); + const fixture = searchFixture(messages(600, [0]), { + beforePage: async () => { started.resolve(); await release.promise; }, + }); + const pending = fixture.run(1); + await started.promise; + await fixture.cancel(); + assert.equal(fixture.counts.closed, 1); + release.resolve(); + const outcome = await pending; + assert.ok(!Array.isArray(outcome)); + assert.equal(outcome.reason, 'aborted'); + assert.equal(fixture.requests.length, 1); + assert.equal(fixture.counts.decoded, 0); + assert.equal(fixture.counts.closed, 1); +}); + +test('discards partial transcript matches on a later read failure while retaining its title', async () => { + const fixture = searchFixture(messages(600, [0]), { + title: 'needleunique title', + beforePage: async (_request, index) => { + if (index === 2) throw new Error('transcript unavailable'); + }, + }); + const hits = await fixture.search(10); + assert.equal(hits.length, 1); + assert.equal(hits[0]!.summary, '任务标题'); + assert.equal(fixture.counts.decoded, 256); + assert.equal(fixture.requests.length, 2); + assert.equal(fixture.counts.closed, 1); +}); + +test('keeps the opening watermark while new transcript messages arrive', async () => { + const durable = messages(600, []); + const fixture = searchFixture(durable, { + beforePage: async (_request, index) => { + if (index === 1) durable.push({ type: 'user', id: 'new', turnId: 'new', ts: 601, text: 'needleunique' }); + }, + }); + assert.deepEqual(await fixture.search(1), []); + assert.equal(fixture.counts.decoded, 600); + assert.ok(fixture.requests.every((request) => request.throughSequence === 599)); +}); + +function messages(count: number, matching: readonly number[]): StoredMessage[] { + const hits = new Set(matching); + return Array.from({ length: count }, (_, index) => ({ + type: 'user', id: `message-${index}`, turnId: `turn-${index}`, ts: index + 1, + text: hits.has(index) ? 'needleunique' : 'ordinary output '.repeat(8), + })); +} + +function searchFixture(durable: StoredMessage[], options: { + sequenceStride?: number; + title?: string; + beforePage?: (request: SessionTranscriptPageInput, index: number) => Promise; +} = {}) { + const sessionId = 'history-session'; + const handlers = new Map(); + const sender = new EventEmitter(); + const event = { sender } as Parameters[0]; + const counts = { opened: 0, closed: 0, decoded: 0 }; + const requests: SessionTranscriptPageInput[] = []; + const reader = transcriptReader(durable, options.sequenceStride); + const catalog: SessionCatalogProjection = { + id: sessionId, revision: 1, + workspace: { target: { kind: 'host_path', path: '/fixture' }, hostCwd: '/fixture' }, + createdAt: 1, activityAt: 1, lastMessageAt: 1, name: options.title ?? 'History', + isFlagged: false, isArchived: false, labels: [], labelsTruncated: false, + hasUnread: false, status: 'active', backend: 'ai-sdk', llmConnectionId: 'fixture', + llmConnectionSlug: 'fixture', connectionLocked: true, model: 'fixture', + permissionMode: 'ask', collaborationMode: 'agent', orchestrationMode: 'default', + }; + registerRuntimeHostSearchIpc({ + ipcMain: { handle: (channel, listener) => { handlers.set(channel, listener); } }, + client: { + listSessions: async () => [catalog], + queryRuntimePolicy: async () => ({ revision: 1, policy: createDefaultRuntimePolicy() }), + openSession: async () => { + counts.opened += 1; + const throughSequence = await reader.readDurableHighWater(sessionId); + const { bootstrap, state } = await createSessionTranscriptBootstrap({ + reader, sessionId, subscriptionId: 'search-subscription', throughSequence, + maxBytes: 16 * 1024, projection: 'owner', + }); + const subscription = new ClientSessionSubscription({ + hostEpoch: 'search-host', subscriptionId: state.subscriptionId, nextSequence: 1, + activeAssistantStreams: [], transcript: bootstrap, + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn: null, goal: null, + queue: { hostEpoch: 'search-host', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + }, async () => { counts.closed += 1; }, async (request) => { + requests.push(request); + await options.beforePage?.(request, requests.length); + updateSubscriberTranscriptHighWater(state, await reader.readDurableHighWater(sessionId)); + return readSessionTranscriptPage({ reader, state, request }); + }, async () => {}); + const decode = (value: unknown) => { + counts.decoded += 1; + return decodeStoredMessage(markPersisted(value)); + }; + return { + ...runtimeHostSessionFixture({ + snapshot: subscription.snapshot, events: subscription, transcript: Promise.resolve([]), + transcriptBootstrap: bootstrap, + decodeTranscriptPage: (page, maxBytes, accountBytes) => + subscription.decodeTranscriptPage(page, decode, maxBytes, accountBytes), + loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), + close: () => subscription.close(), + }), + loadTranscript: () => subscription.loadTranscript(decode), + }; + }, + }, + }); + const run = async (limit: number): Promise => { + const request: SearchRequest = { source: 'thread', query: 'needleunique', limit }; + const outcome: SearchResult[] | SearchError = await handlers.get('search:thread')!(event, request, 'query'); + assert.equal(sender.listenerCount('destroyed'), 0); + assert.equal(sender.listenerCount('render-process-gone'), 0); + return outcome; + }; + return { + counts, requests, run, + cancel: () => handlers.get('search:thread:cancel')!(event, 'query'), + async search(limit: number): Promise { + const outcome = await run(limit); + assert.ok(Array.isArray(outcome)); + return outcome; + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts index dc36bbcb28..39b2f52bcb 100644 --- a/apps/desktop/src/main/__tests__/thread-search.test.ts +++ b/apps/desktop/src/main/__tests__/thread-search.test.ts @@ -23,6 +23,7 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session'; import { SNIPPET_MAX_CODE_POINTS, TOOL_RESULT_SCAN_CAP_BYTES, + ThreadSearchReadError, capCodePoints, collectSearchableText, findMatch, @@ -101,8 +102,11 @@ function makeDeps(entries: Record, privacyPayload: unknown = { in async listSessions() { return Object.values(entries).map((entry) => entry.session); }, - async readMessages(sessionId: string) { - return entries[sessionId]?.messages ?? []; + async *readMessagePages(sessionId: string) { + yield { + messages: (entries[sessionId]?.messages ?? []).map((message, sequence) => ({ sequence, message })), + hasMore: false, + }; }, async getPrivacyContext() { return privacyPayload; @@ -212,11 +216,11 @@ describe('runThreadSearch', () => { messages: [userMessage('needle')], }, }), - async readMessages(sessionId, signal) { + async *readMessagePages(sessionId, signal) { reads += 1; assert.equal(signal, controller.signal); if (sessionId === 'newest') controller.abort(); - return []; + yield { messages: [], hasMore: false }; }, }, { abortSignal: controller.signal }, @@ -459,13 +463,59 @@ describe('runThreadSearch', () => { expectResults( await runThreadSearch( { source: 'thread', query: 'diagnostic', limit: 5 }, - { ...deps, readMessages: async () => null }, + { ...deps, readMessagePages: () => { throw new ThreadSearchReadError('unavailable'); } }, ), ), [], ); }); + it('keeps title priority and the result budget across pages and sessions', async () => { + const deps = makeDeps({ + older: { session: session({ id: 'older', name: 'needle older', lastMessageAt: 1 }), messages: [] }, + newer: { session: session({ id: 'newer', name: 'needle newer', lastMessageAt: 2 }), messages: [] }, + }); + const opened: string[] = []; + const closed: string[] = []; + const response = await runThreadSearch({ source: 'thread', query: 'needle', limit: 5 }, { + ...deps, + async *readMessagePages(sessionId) { + opened.push(sessionId); + try { + yield { messages: [{ sequence: 8, message: userMessage('needle first', `${sessionId}-first`) }], hasMore: true }; + yield { messages: [{ sequence: 24, message: userMessage('needle second', `${sessionId}-second`) }], hasMore: false }; + } finally { + closed.push(sessionId); + } + }, + }); + assert.deepEqual(expectResults(response).map(({ target }) => [target?.sessionId, target?.sequence]), [ + ['newer', undefined], ['newer', 8], ['newer', 24], ['older', undefined], ['older', 8], + ]); + assert.deepEqual(opened, ['newer', 'older']); + assert.deepEqual(closed, ['newer', 'older']); + assert.equal(expectResults(response).at(-1)!.truncated, true); + }); + + it('continues to the next session when closing a reader fails after an early match', async () => { + const deps = makeDeps({ + broken: { session: session({ id: 'broken', lastMessageAt: 2 }), messages: [] }, + healthy: { session: session({ id: 'healthy', lastMessageAt: 1 }), messages: [] }, + }); + const response = await runThreadSearch({ source: 'thread', query: 'needle', limit: 1 }, { + ...deps, + async *readMessagePages(sessionId) { + try { + yield { messages: [{ sequence: 8, message: userMessage('needle') }], hasMore: sessionId === 'broken' }; + } finally { + if (sessionId === 'broken') throw new ThreadSearchReadError('close failed'); + } + }, + }); + assert.deepEqual(expectResults(response).map(({ target }) => target?.sessionId), ['healthy']); + assert.equal(response.ok && response.truncated, false); + }); + it('blocks active or unverifiable privacy state before scanning', async () => { for (const privacyPayload of [ { incognitoActive: true }, @@ -486,9 +536,9 @@ describe('runThreadSearch', () => { listCalls++; return []; }, - async readMessages() { + async *readMessagePages() { readCalls++; - return []; + yield { messages: [], hasMore: false }; }, }, ); diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts index 564e27e2a5..8506708c3e 100644 --- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-search-ipc-main.ts @@ -18,12 +18,14 @@ */ import type { SearchResult } from '@maka/core/search'; -import { runThreadSearch } from '@maka/core/thread-search'; +import { runThreadSearch, ThreadSearchReadError, type ThreadSearchMessagePage } from '@maka/core/thread-search'; +import { SESSION_TRANSCRIPT_PAGE_MAX_BYTES } from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { WebContents } from 'electron'; +import { DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES } from '../preload/transcript-contract.js'; import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js'; import { - readWithFallback, + rethrowReconnectableReadFailure, type ReconnectableReadIpcMain, } from './ipc-reconnect-policy.js'; @@ -72,25 +74,7 @@ export function registerRuntimeHostSearchIpc( 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), + readMessagePages: (sessionId, signal) => readSearchMessagePages(deps.client, sessionId, signal), getPrivacyContext: async () => ({ incognitoActive: (await deps.client.queryRuntimePolicy()).policy.privacy .incognitoActive, @@ -112,6 +96,59 @@ export function registerRuntimeHostSearchIpc( }); } +async function* readSearchMessagePages( + client: Pick, + sessionId: string, + signal?: AbortSignal, +): AsyncGenerator { + try { + if (signal?.aborted) return; + const session = await client.openSession(sessionId); + // Only this search owns the handle. Closing also stops a fragment + // continuation after its in-flight reply, before another page is requested. + 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; + const bootstrap = session.transcriptBootstrap.durable; + const readPage = (cursor: string | null) => session.loadTranscriptPage({ + direction: 'newer', throughSequence: bootstrap.throughSequence, + cursor, anchorSequence: null, maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }); + // A complete bootstrap already contains the whole small transcript. + // Otherwise start at the oldest message, with the opening watermark. + let cursor: string | null = null; + let page = bootstrap.nextCursor === null ? bootstrap : await readPage(cursor); + let lastSequence = -1; + for (;;) { + if (signal?.aborted) return; + const decoded = await session.decodeTranscriptPage(page, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES); + if (signal?.aborted) return; + const messages = decoded.messages.map(({ identity, message }) => { + if (identity <= lastSequence) throw new Error('Search transcript sequence did not advance'); + lastSequence = identity; + return { sequence: identity, message }; + }); + if (decoded.nextCursor !== null && (messages.length === 0 || decoded.nextCursor === cursor)) { + throw new Error('Search transcript cursor did not advance'); + } + yield { messages, hasMore: decoded.nextCursor !== null }; + if (decoded.nextCursor === null || signal?.aborted) return; + cursor = decoded.nextCursor; + page = await readPage(cursor); + } + } finally { + signal?.removeEventListener('abort', cancelRead); + await close(); + } + } catch (error) { + rethrowReconnectableReadFailure(error); + throw new ThreadSearchReadError('Session transcript could not be read.', { cause: error }); + } +} + function projectDesktopSearchResult(result: SearchResult): SearchResult { if (!result.target) return result; return { diff --git a/docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md b/docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md new file mode 100644 index 0000000000..97935b18e9 --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md @@ -0,0 +1,143 @@ + + +**实现更新(2026-09-20):** 历史搜索已改为消费正序 `readMessagePages`,达到结果预算后立即结束迭代。完整小会话复用 bootstrap;其他会话固定打开时的 watermark,从最早位置读起。每条结果保留源 sequence,普通读取失败撤销本 Session 的正文命中,取消和 reader 关闭语义保留。 + +修复后的同夹具结果见 [implementation-results.json](./history-search-diagnosis-2026-09-20/implementation-results.json):首条命中、limit=1 时,解码 **20,000 → 256**、额外分页 **78 → 1**;前十条命中、limit=10 得到相同改善。页末命中保留 `truncated=true`。无命中仍需全扫,因保留尾部 bootstrap,当前该夹具额外分页为 79 次、原始字节约多 16KiB。下文保留实现前的诊断与基线。 + +**实现验证:** 新增真实 IPC → 搜索器 → subscription → Host pager/decoder 的分页回归测试,先观察到 `20000 !== 256` 失败,再验证修复通过。Desktop 搜索、分页、IPC 共 40 项测试和 UI 搜索相关 6 项测试通过;Core、Desktop、UI 类型检查通过。代码规范与需求审查均未发现实现问题,需求审查另以 8,000 个确定性场景对比新旧结果、截断及 continuation 语义。新版探针的 `--check-budget` 通过;[implementation-manifest.json](./history-search-diagnosis-2026-09-20/implementation-manifest.json) 记录修复后源文件哈希,其 commit 字段为测量时的基线 HEAD。 + +全仓 `npm test` 已完整运行,构建成功,测试未全绿。Shell PATH、执行器取消、peer invitation 用例独立重跑通过;Storage 子进程就绪检查受 SQLite ExperimentalWarning 干扰,使用 `NODE_NO_WARNINGS=1` 重跑通过;Eval 默认 Python 3.9 不兼容,改用 Python 3.12 后 87 项测试通过(12 项跳过)。Runtime Host 的 `production Host publishes and retires an implementation child patch` 独立重跑仍报 `Hosted real-model Turn did not become terminal`;该用例不经过本次搜索路径,留待单独排查。 + +发布前补验:全局 `npm run lint`、`npm run build`、`npm run typecheck` 及 Desktop/UI 两项 Knip 检查通过。`npm run format:check` 因工作区原有的 16 个未跟踪文件失败;限定全部 Git 跟踪文件的同一 Biome formatter 检查通过,本次变更的格式及提交检查均通过。问题已单独提交为 [Issue #5523](https://github.com/apache/maka/issues/5523),关联 #2913 / #4677,不关闭这两个范围更大的跟踪项。 + +**实现前诊断与基线(以下保留原始记录)** + +**根因是搜索的数据接口要求完整消息数组:结果数量限制只能停止匹配,无法提前停止拉页和解码。** 基线源码稳定复现“limit=1、最早一条消息命中,仍解码 20,000 条并追加请求 78 页”。现有取消有效,分页协议也已经支持正向读取;应调整搜索消费消息的方式。 + +原始诊断仅新增本文、探针和结果。源码基线为 `d3292393c575d2019ca406a5048099abf3399e0f`,Node v24.14.0 / macOS arm64。原始问题来自本地性能审计 `latest-code-audit-2026-09-19.zh-CN.md` 第 4 项。[manifest.json](./history-search-diagnosis-2026-09-20/manifest.json) 记录关键源码 SHA-256。 + +**可复现的证据** + +扩展探针经过真实 Desktop `search:thread` handler、`runThreadSearch`、`ClientSessionSubscription`、`decodeStoredMessage` 和 Host pager。只有 transcript storage 与传输使用合成实现;每条消息独立 Turn,符合 16KiB bootstrap、512KiB / 256 条分页上限。直接打包 TypeScript 源码及工作区依赖,不读取旧 dist。 + +| 场景 | 消息数 | 结果 limit | 完整消息解码 | bootstrap 之外的请求 | 检查正文字段次数 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 最早消息命中 | 2 | 1 | 2 | 0 | 1 | +| 最早消息命中 | 256 | 1 | 256 | 1 | 1 | +| 最早消息命中 | 5,000 | 1 | 5,000 | 20 | 1 | +| 最早消息命中 | 20,000 | 1 | 20,000 | 78 | 1 | +| 只有最早消息命中 | 20,000 | 10 | 20,000 | 78 | 20,000 | +| 最新消息命中 | 20,000 | 1 | 20,000 | 78 | 20,000 | +| 无命中 | 20,000 | 1 | 20,000 | 78 | 20,000 | +| 标题命中 | 20,000 | 1 | 0 | 0 | 0 | + +计数重复运行一致,详见 [results.json](./history-search-diagnosis-2026-09-20/results.json)。读取第一条候选正文时,探针已记录 `decodedMessages=20000, pageRequests=78`。完整 JSON 解析数也是 20,000,不只是最后一次 schema decoder 的调用数。2 条消息就能复现多余解码;256 条已跨过 bootstrap,可以复现多余分页。 + +20,000 条夹具的 bootstrap 包含最新 79 条,剩余 19,921 条需要 `ceil(19921 / 256) = 78` 次顺序请求。原始消息字节合计 4,106,558(约 3.92MiB),不包含 base64 膨胀、协议 JSON、Session snapshot 或帧开销。真实大消息、同 Turn 分页边界会改变页数,78 不是固定常数。 + +原审计探针在本机重跑约 13–14ms,但不含真实网络、磁盘、Host RuntimeEvent 投影和 UI 绘制;扩展探针还增加了计数与真实 schema decoder,不能把两者耗时作为 A/B。可靠证据是请求量、解码量与匹配起点。若每个分页实际耗时 R,当前 78 个顺序请求还会贡献约 `78 × R` 的等待,这只是成本表达式,不是实测端到端延迟。 + +**调用链与根因** + +```mermaid +flowchart TD + A[搜索请求 limit=1] --> B[校验隐私、排序 Session、匹配标题] + B --> C[readMessages 要求完整数组] + C --> D[openSession:最新 16KiB bootstrap] + D --> E[loadTranscript:older 方向逐页读到 cursor=null] + E --> F[解析全部消息、反转为正序、schema 解码] + F --> G[从最早消息开始匹配] + G --> H[首条命中,下一次循环因 limit 停止] +``` + +1. [ThreadSearchDeps.readMessages](../../packages/core/src/thread-search.ts#L100) 返回 `Promise`。消费方必须等整个 Promise 完成,无法在某条命中后让生产方停下。 +2. [Desktop adapter](../../apps/desktop/src/main/runtime-host-search-ipc-main.ts#L75) 每次为搜索打开独立 Session,调用 `session.loadTranscript()`,最后关闭。`limit` 未传入此层;加载缓存仅属于本次 handle,下一次查询会新建 handle。 +3. [openSession](../../apps/desktop/src/main/runtime-host-client.ts#L1648) 请求 `transcript: { kind: 'tail', maxBytes: 16KiB }`。[loadTranscriptSource](../../packages/runtime-host/src/client/session-subscription.ts#L325) 只以 `cursor === null` 作为正常读完条件,逐页 `await`,并保留全部解码对象。 +4. assembler 在 [completeCurrent](../../packages/runtime-host/src/client/session-subscription.ts#L657) 解析每条完整 JSON,在 [finish](../../packages/runtime-host/src/client/session-subscription.ts#L568) 将倒序结果反转;外层 [loadTranscript](../../packages/runtime-host/src/client/session-subscription.ts#L214) 再执行 `messages.map(decodeMessage)`。 +5. [runThreadSearch](../../packages/core/src/thread-search.ts#L297) 拿到完整数组后才逐条匹配;第 307 行的 `maxResults` 检查确实能停止后续匹配,但此时读取和解码已经完成。 + +因此,当前命中发生在第 k 条、仅需少量结果时,单 Session 的加载仍为全量消息/字节成本,匹配才按 k 停止。保存全部消息对象使加载侧内存也随会话正文增长。`MAX_SESSIONS_SCANNED=200`、结果上限、snippet 字节上限都不能约束一段长会话的读取量。 + +方向是第二个约束:**Session 按 lastMessageAt 从新到旧排序,同一 Session 内按消息从旧到新匹配**,且该 Session 的标题先于正文。当前 bootstrap 从尾部出发;不能直接沿旧的 `older` cursor 逐页命中就停,否则会改变结果顺序,且最早消息仍要等最后一页。 + +**已排除的原因与适用范围** + +- 不是 `limit` 完全失效:同一夹具 limit=1 只检查 1 条正文,limit=10 且仅 1 个命中时检查 20,000 条;两者都提前加载全量。 +- 不是取消缺失:[adapter](../../apps/desktop/src/main/runtime-host-search-ipc-main.ts#L79) 把 AbortSignal 接到本次 handle.close,subscription 在在途页返回后再次检查关闭状态。探针在第一次追加请求时取消:只发出 1 页、handle 只关闭一次,返回 `aborted`;bootstrap 的 79 条 JSON 已解析,但没有继续 schema 解码或匹配。取消不能撤回已经发出的那一页,也不代表能打断一段正在执行的同步解码。 +- 标题已满足 limit 时不会打开正文 Session,属于现有有效优化。 +- [搜索框实际请求 limit=10](../../packages/ui/src/search-modal.tsx#L101)。limit=1 是最小反例;真实界面需要较早找到 10 条才有同类提前停止收益。无命中、只有少量命中或命中很晚,仍可能扫描全段。 +- [多 Host 汇总](../../apps/desktop/src/preload/multi-host-thread-search.ts#L84) 等所有 Host 完成后再截断结果,各 Host 内仍走上述链路。这会放大等待,但不是本次 78 页的来源。 + +**已验证的修复方向** + +现有 `session.transcript.page` 支持 `direction: 'newer'`,无需新增协议即可从最早位置开始读。以打开时的 watermark 固定本次扫描: + +```ts +await session.loadTranscriptPage({ + direction: 'newer', + throughSequence: session.transcriptBootstrap.durable.throughSequence, + cursor: null, + anchorSequence: null, + maxBytes: 512 * 1024, +}); +``` + +[Host pager](../../packages/runtime-host/src/server/session-transcript-pager.ts#L193) 对该请求从 position=0 开始。`anchorSequence: 0` 意味着从它之后开始,会跳过 sequence=0,应使用 null。后续只使用同 subscription、方向、watermark 对应的 cursor。 + +隔离实验保留现有打开 Session 的尾部 bootstrap,随后正向读取最早一页,将这页交给**未经修改的匹配器**: + +| limit=1、最早消息命中 | 当前完整加载 | 仅正向首页的可行性实验 | +| --- | ---: | ---: | +| 额外分页请求 | 78 | 1 | +| 完整消息解码 | 20,000 | 256 | +| 原始消息字节,含 bootstrap | 4,106,558 | 67,365 | +| 首个命中 Turn / 消息位置 | turn-0 / 0 | turn-0 / 0 | +| 未读取的历史仍存在 | 否 | 是,cursor 非空 | + +前十条消息均命中、limit=10 的对照也保持全部十个 Turn 的顺序,额外分页请求同样由 78 降为 1,解码由 20,000 降为 256。这验证了现有协议、assembler、匹配逻辑的组合可行性,**没有实现通用的逐页搜索,也不是生产修复后的性能数据**。实验仍支付一次尾部 bootstrap 的 I/O,但不解码它;小会话已在 bootstrap 完整呈现时,正式实现可直接复用。 + +建议的改动集中在 Core 搜索依赖与 Desktop adapter:让搜索器按需拉取正序消息页或消费惰性 `AsyncIterable`;每页保留“是否还有后续内容”,每条保留身份;达到结果预算就结束迭代并在 finally 关闭专用 handle。复用已有 `loadTranscriptPage` / `decodeTranscriptPage`,让正文加载成本随已扫描前缀及当前页增长,而不是总会话长度。分页大小决定最多多读一页的成本,不应把结果 limit 直接当成消息条数上限。 + +当前页的最后一条消息如果跨页,`decodeTranscriptPage` 会继续拉取分片来完成它。因此“只读一页、最多 256 条”是本夹具完整小消息下的数字;真实字节上界还需考虑单消息装配预算及 Host Turn 投影成本。 + +**实现时需要明确的边界** + +- **全局预算与截断标记。** 不能简单对每页独立运行旧函数再拼接:结果数、总 snippet 字节、Session 扫描预算、标题优先级都要跨页保留。探针把唯一命中放在最早页第 256 条:原路径标记 `truncated=true`,只把首页作为完整数组的实验得到 `false`,但 Host cursor 明明还有下一页。应把页的 hasMore 纳入判断,且达到 limit 后在调用迭代器 next 之前停止,避免恰在页尾时又拉一页。 +- **两种 cursor 不可混用。** Core 的 `nextCursor` 目前用于扫完 200 个 Session 后继续,绑定 query、Session ID 和时间;Host cursor 是本 subscription 的消息位置,绑定方向与 watermark。不能在关闭 handle 后把 Host cursor 作为 Core 对外 continuation 返回。 +- **正文身份与数组下标。** 当前结果写 `sequence: messageIndex`,完整加载时已丢弃 assembler.identity。合成稀疏 sequence `[0,8,16]` 中第二条命中,现有返回 sequence=1,源 identity=8;真实 reader 也以 `ordinal * 8 + offset` 生成可能稀疏的坐标。逐页后不能把每页下标归零,也不能将数组下标当 Host cursor/anchor。应保留真实 identity,并明确现有结果字段的兼容语义。当前 Renderer [按 turnId 查 Turn 索引定位](../../apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts#L145),本次未证明用户点击会跳错位置。 +- **取消、错误和过滤。** 保留 requestId / 窗口退出取消、隐私前置校验、凭证查询拒绝、先脱敏再匹配、排除 thinking / 系统消息,以及归档与 revision 过滤。当前 adapter 读失败时整个 Session 正文返回 null;逐页累积后若后页失败,要明确是否回滚本 Session 已收集的内容结果,以保持原语义。 +- **没有足够命中的最坏情况。** 增量消费消除可避免的全量预加载,不会让无索引的任意子串搜索在无命中时变成常数成本。若还要优化这一类查询,应另评估 Host 侧搜索或搜索投影;这会涉及存储与匹配语义,不是本次小范围改动的必要前提。 + +**验证入口** + +现有 Core 单测通常直接提供完整内存数组,IPC 测试也多数 mock `loadTranscript()`,能验证结果与取消,却看不到真实分页读取量。本次探针补上 handler → matcher → pager / assembler 这一组合边界;正式修复应在此类边界用请求数与解码数做回归断言,而不只断言结果条数。 + +验收应覆盖:最早命中且仍有多页、界面 limit=10 的前十条命中、命中位于页末、跨消息分片、最新命中与无命中、标题先满足预算、在途页取消、稀疏 identity,以及分页失败/隐私过滤。保留现有 Session 顺序与会话内正序结果。 + +从仓库根目录运行: + +```sh +search_diag_tmp=$(mktemp -d "${TMPDIR:-/tmp}/maka-history-search.XXXXXX") +node docs/performance/history-search-diagnosis-2026-09-20/build.mjs "$search_diag_tmp" +node docs/performance/history-search-diagnosis-2026-09-20/probe.mjs "$search_diag_tmp" +node docs/performance/history-search-diagnosis-2026-09-20/probe.mjs "$search_diag_tmp" --check-budget +``` + +普通运行验证夹具、命中、身份、关闭次数及正向首页能力。诊断时的 `--check-budget` 以 `decoded 20000/20000, requested 78 more pages` 失败;实现后脚本已迁移到新接口,现应通过“只解码 256 条、追加请求 1 页、页末保留截断标记”的断言。历史 [results.json](./history-search-diagnosis-2026-09-20/results.json) 保留原始基线,新的测量独立保存。 diff --git a/docs/performance/history-search-diagnosis-2026-09-20/build.mjs b/docs/performance/history-search-diagnosis-2026-09-20/build.mjs new file mode 100644 index 0000000000..42b408283d --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20/build.mjs @@ -0,0 +1,113 @@ +/* + * 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 { build } from 'esbuild'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +if (!process.argv[2]) throw new Error('Pass a temporary output directory'); +const repo = process.cwd(); +const out = resolve(process.argv[2]); +mkdirSync(out, { recursive: true }); +if (!existsSync(join(out, 'node_modules'))) { + symlinkSync(join(repo, 'node_modules'), join(out, 'node_modules'), 'dir'); +} +const exports = [ + ['ClientSessionSubscription', 'packages/runtime-host/src/client/session-subscription.ts'], + ['runThreadSearch', 'packages/core/src/thread-search.ts'], + ['decodeStoredMessage', 'packages/core/src/session.ts'], + ['createDefaultRuntimePolicy', 'packages/core/src/runtime-policy.ts'], + ['registerRuntimeHostSearchIpc', 'apps/desktop/src/main/runtime-host-search-ipc-main.ts'], + [ + 'createSessionTranscriptBootstrap, readSessionTranscriptPage', + 'packages/runtime-host/src/server/session-transcript-pager.ts', + ], +]; +await build({ + stdin: { + contents: exports + .map(([names, path]) => `export { ${names} } from ${JSON.stringify(join(repo, path))};`) + .join('\n'), + loader: 'ts', + resolveDir: repo, + sourcefile: 'history-search-diagnosis.ts', + }, + outfile: join(out, 'source.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + packages: 'external', + sourcemap: 'inline', + banner: { + js: "import { createRequire as __diagnosisRequire } from 'node:module'; const require = __diagnosisRequire(import.meta.url);", + }, + plugins: [ + { + name: 'workspace-sources', + setup(builder) { + builder.onResolve({ filter: /^@maka\// }, ({ path }) => { + const [, name, ...rest] = path.split('/'); + const base = join(repo, 'packages', name); + const pkg = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')); + const exported = pkg.exports[rest.length ? `./${rest.join('/')}` : '.']; + if (typeof exported !== 'string') throw new Error(`Unsupported export ${path}`); + const target = resolve( + base, + exported.replace(/^\.\/dist\//, './src/').replace(/\.js$/, '.ts'), + ); + if (existsSync(target)) return { path: target }; + if (existsSync(`${target}x`)) return { path: `${target}x` }; + throw new Error(`Missing source ${target}`); + }); + }, + }, + ], +}); +const files = [ + ...new Set([ + ...exports.map(([, path]) => path), + 'apps/desktop/src/main/runtime-host-client.ts', + 'packages/runtime-host/src/protocol/session-transcript.ts', + 'packages/runtime-host/src/server/session-transcript-reader.ts', + ]), +]; +writeFileSync( + join(out, 'manifest.json'), + JSON.stringify( + { + commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + node: process.version, + platform: process.platform, + arch: process.arch, + sourceSha256: Object.fromEntries( + files.map((path) => [ + path, + createHash('sha256') + .update(readFileSync(join(repo, path))) + .digest('hex'), + ]), + ), + }, + null, + 2, + ) + '\n', +); +console.log(join(out, 'source.mjs')); diff --git a/docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json b/docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json new file mode 100644 index 0000000000..e12eb29b25 --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json @@ -0,0 +1,17 @@ +{ + "commit": "d3292393c575d2019ca406a5048099abf3399e0f", + "node": "v24.14.0", + "platform": "darwin", + "arch": "arm64", + "sourceSha256": { + "packages/runtime-host/src/client/session-subscription.ts": "b24285e94747d4831ff7850be148adf628f33b95a6c7bfba490151741cb26034", + "packages/core/src/thread-search.ts": "4f57aee6c89116ae1f71a5525dfb15597324b18b9e9664304d91892fb4adc8f1", + "packages/core/src/session.ts": "609d31188b2ea61a964b76dcd4f5f06a1ee3fe7d73a70e474e437a9c38fda725", + "packages/core/src/runtime-policy.ts": "9fb3410f0acda7cf6817d708d354b63acc957760e05d32e8a583c276a0816040", + "apps/desktop/src/main/runtime-host-search-ipc-main.ts": "c53dbe5a14d88c58d4805125f1b3d9bdd121d5a52ef480b9f7060c82adb5c8c1", + "packages/runtime-host/src/server/session-transcript-pager.ts": "6504758f52a7e9b10f4c22f56ab296394b3ac1e71f871ca9f945b96eec0501a0", + "apps/desktop/src/main/runtime-host-client.ts": "1e8d603e232fab7315321cbd5629e1efa1699dfcb060d015b83529858165d550", + "packages/runtime-host/src/protocol/session-transcript.ts": "91ee66c382f0195fbed57e0a1b5e38dd49f0d6d965349a4baf4cf21a151f3161", + "packages/runtime-host/src/server/session-transcript-reader.ts": "2d9106a94d6b561140bcbbc3d5b257e8e981950aa09bd28d98fd584e1191d7e8" + } +} diff --git a/docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json b/docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json new file mode 100644 index 0000000000..80c0d94520 --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json @@ -0,0 +1,586 @@ +[ + { + "label": "first-hit-2", + "total": 2, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 0, + "decodedMessages": 2, + "messageJsonParses": 2, + "scannedMessageFields": 1, + "bootstrapMessages": 2, + "rawBytes": 274, + "beforeFirstCandidate": { + "decodedMessages": 2, + "pageRequests": 0 + }, + "directions": [], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-256", + "total": 256, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 1, + "bootstrapMessages": 81, + "rawBytes": 67293, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-5000", + "total": 5000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 1, + "bootstrapMessages": 80, + "rawBytes": 67332, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-20000", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 1, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-limit-10", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 10, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 79, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 20000, + "bootstrapMessages": 79, + "rawBytes": 4122911, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + } + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "ten-early-hits-baseline", + "total": 20000, + "hitIndex": 0, + "hitCount": 10, + "limit": 10, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 10, + "bootstrapMessages": 79, + "rawBytes": 66321, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 10, + "matchedTurnIds": [ + "turn-0", + "turn-1", + "turn-2", + "turn-3", + "turn-4", + "turn-5", + "turn-6", + "turn-7", + "turn-8", + "turn-9" + ], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + } + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "ten-early-hits-feasibility", + "total": 20000, + "hitIndex": 0, + "hitCount": 10, + "limit": 10, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 10, + "bootstrapMessages": 79, + "rawBytes": 66321, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 10, + "matchedTurnIds": [ + "turn-0", + "turn-1", + "turn-2", + "turn-3", + "turn-4", + "turn-5", + "turn-6", + "turn-7", + "turn-8", + "turn-9" + ], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0, + "messageId": "message-0", + "matchKind": "user_message", + "messageTimestamp": 1 + } + } + }, + "forwardHasMore": true, + "pageOnlyTruncated": true, + "matchingFixtureSequence": 0 + }, + { + "label": "last-hit", + "total": 20000, + "hitIndex": 19999, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 79, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 20000, + "bootstrapMessages": 79, + "rawBytes": 4122795, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-19999"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-19999", + "sequence": 19999 + } + } + }, + "matchingFixtureSequence": 19999 + }, + { + "label": "no-hit", + "total": 20000, + "hitIndex": -1, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 79, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 20000, + "bootstrapMessages": 79, + "rawBytes": 4123027, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 0, + "matchedTurnIds": [], + "first": null + } + }, + { + "label": "title-only", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 0, + "closed": 0, + "pageRequests": 0, + "decodedMessages": 0, + "messageJsonParses": 0, + "scannedMessageFields": 0, + "bootstrapMessages": 0, + "rawBytes": 0, + "beforeFirstCandidate": null, + "directions": [], + "outcome": { + "results": 1, + "matchedTurnIds": [null], + "first": { + "source": "thread", + "title": "needleunique", + "summary": "任务标题", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis" + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "cancel-first-page", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 0, + "messageJsonParses": 0, + "scannedMessageFields": 0, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": null, + "directions": ["newer"], + "outcome": { + "ok": false, + "reason": "aborted", + "message": "History search was aborted." + }, + "matchingFixtureSequence": 0 + }, + { + "label": "oldest-page-feasibility", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 1, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0, + "messageId": "message-0", + "matchKind": "user_message", + "messageTimestamp": 1 + }, + "truncated": true + } + }, + "forwardHasMore": true, + "pageOnlyTruncated": true, + "matchingFixtureSequence": 0 + }, + { + "label": "page-edge-hit-baseline", + "total": 20000, + "hitIndex": 255, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 256, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-255"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-255", + "sequence": 255 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 255 + }, + { + "label": "page-edge-hit-feasibility", + "total": 20000, + "hitIndex": 255, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 256, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-255"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-255", + "sequence": 255, + "messageId": "message-255", + "matchKind": "user_message", + "messageTimestamp": 256 + } + } + }, + "forwardHasMore": true, + "pageOnlyTruncated": false, + "matchingFixtureSequence": 255 + }, + { + "label": "sparse-sequence-control", + "total": 3, + "hitIndex": 1, + "hitCount": 1, + "limit": 1, + "stride": 8, + "opened": 1, + "closed": 1, + "pageRequests": 0, + "decodedMessages": 3, + "messageJsonParses": 3, + "scannedMessageFields": 2, + "bootstrapMessages": 3, + "rawBytes": 469, + "beforeFirstCandidate": { + "decodedMessages": 3, + "pageRequests": 0 + }, + "directions": [], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-1"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-1", + "sequence": 8 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 8 + } +] diff --git a/docs/performance/history-search-diagnosis-2026-09-20/manifest.json b/docs/performance/history-search-diagnosis-2026-09-20/manifest.json new file mode 100644 index 0000000000..566004f540 --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20/manifest.json @@ -0,0 +1,17 @@ +{ + "commit": "d3292393c575d2019ca406a5048099abf3399e0f", + "node": "v24.14.0", + "platform": "darwin", + "arch": "arm64", + "sourceSha256": { + "packages/runtime-host/src/client/session-subscription.ts": "b24285e94747d4831ff7850be148adf628f33b95a6c7bfba490151741cb26034", + "packages/core/src/thread-search.ts": "085f10a0a352d991a09f60e17833161a96d5c7cbaef5fcda559a5c466c5f72b5", + "packages/core/src/session.ts": "609d31188b2ea61a964b76dcd4f5f06a1ee3fe7d73a70e474e437a9c38fda725", + "packages/core/src/runtime-policy.ts": "9fb3410f0acda7cf6817d708d354b63acc957760e05d32e8a583c276a0816040", + "apps/desktop/src/main/runtime-host-search-ipc-main.ts": "0e2101bdb635e299b2ac86a1d4600020f2e40e217c7724aa358875be110fefbf", + "packages/runtime-host/src/server/session-transcript-pager.ts": "6504758f52a7e9b10f4c22f56ab296394b3ac1e71f871ca9f945b96eec0501a0", + "apps/desktop/src/main/runtime-host-client.ts": "1e8d603e232fab7315321cbd5629e1efa1699dfcb060d015b83529858165d550", + "packages/runtime-host/src/protocol/session-transcript.ts": "91ee66c382f0195fbed57e0a1b5e38dd49f0d6d965349a4baf4cf21a151f3161", + "packages/runtime-host/src/server/session-transcript-reader.ts": "2d9106a94d6b561140bcbbc3d5b257e8e981950aa09bd28d98fd584e1191d7e8" + } +} diff --git a/docs/performance/history-search-diagnosis-2026-09-20/probe.mjs b/docs/performance/history-search-diagnosis-2026-09-20/probe.mjs new file mode 100644 index 0000000000..a15275dd44 --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20/probe.mjs @@ -0,0 +1,415 @@ +/* + * 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. + */ + +// Diagnostic only. Real IPC handler, matcher, subscription assembler and Host +// pager; synthetic transcript storage and transport. No database/UI latency. +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +if (!process.argv[2]) throw new Error('Pass the temporary bundle directory'); +const out = resolve(process.argv[2]); +const { + ClientSessionSubscription, + runThreadSearch, + decodeStoredMessage, + createDefaultRuntimePolicy, + registerRuntimeHostSearchIpc, + createSessionTranscriptBootstrap, + readSessionTranscriptPage, +} = await import(pathToFileURL(join(out, 'source.mjs')).href); +const QUERY = 'needleunique'; +const SESSION = 'history-search-diagnosis'; + +function catalogSession(name) { + return { + id: SESSION, + revision: 1, + workspace: { target: { kind: 'host_path', path: '/fixture' }, hostCwd: '/fixture' }, + createdAt: 1, + activityAt: 1, + lastMessageAt: 1, + name, + isFlagged: false, + isArchived: false, + labels: [], + labelsTruncated: false, + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionId: 'fixture', + llmConnectionSlug: 'fixture', + connectionLocked: true, + model: 'fixture', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }; +} + +function fixture(total, hitIndex, stride = 1, hitCount = 1) { + const rows = Array.from({ length: total }, (_, index) => { + const bytes = Buffer.from( + JSON.stringify({ + type: 'user', + id: `message-${index}`, + turnId: `turn-${index}`, + ts: index + 1, + text: + hitIndex >= 0 && index >= hitIndex && index < hitIndex + hitCount + ? QUERY + : 'ordinary output '.repeat(8), + }), + ); + return { sequence: index * stride, bytes }; + }); + const throughSequence = rows.at(-1)?.sequence ?? null; + const reader = { + async readDurablePage(sessionId, request) { + assert.equal(sessionId, SESSION); + const older = request.direction === 'older'; + const position = request.position ?? (older ? throughSequence : 0); + let index = older ? Math.floor(position / stride) : Math.ceil(position / stride); + const fragments = []; + let rawBytes = 0; + let next = null; + while (index >= 0 && index < rows.length) { + const { sequence, bytes } = rows[index]; + assert.ok(sequence <= request.throughSequence); + const edge = + sequence === request.position && request.byteOffset !== undefined + ? request.byteOffset + : older + ? bytes.length + : 0; + const remaining = older ? edge : bytes.length - edge; + if ( + fragments.length === request.maxMessages || + rawBytes === request.maxBytes || + (fragments.length > 0 && rawBytes + remaining > request.maxBytes) + ) { + next = { position: sequence, byteOffset: null }; + break; + } + const size = Math.min(remaining, request.maxBytes - rawBytes); + const byteOffset = older ? edge - size : edge; + fragments.push({ + sequence, + byteOffset, + totalBytes: bytes.length, + payloadDigest: null, + data: bytes.subarray(byteOffset, byteOffset + size), + }); + rawBytes += size; + if (size < remaining) { + next = { position: sequence, byteOffset: older ? byteOffset : byteOffset + size }; + break; + } + index += older ? -1 : 1; + } + return { + throughSequence: request.throughSequence, + fragments, + rawBytes, + next, + endsAtTurnBoundary: next?.byteOffset == null, + }; + }, + }; + return { reader, throughSequence }; +} + +async function runScenario({ + label, + total, + hitIndex = 0, + limit = 1, + titleHit = false, + abortAtPage, + firstForwardPageOnly = false, + stride = 1, + hitCount = 1, +}) { + const data = fixture(total, hitIndex, stride, hitCount); + const counts = { + opened: 0, + closed: 0, + pageRequests: 0, + decodedMessages: 0, + messageJsonParses: 0, + scannedMessageFields: 0, + bootstrapMessages: 0, + rawBytes: 0, + beforeFirstCandidate: null, + }; + const requestedDirections = new Set(); + const handlers = new Map(); + const event = { sender: new EventEmitter() }; + const name = titleHit ? QUERY : 'History review'; + const openSession = async () => { + counts.opened += 1; + const { bootstrap, state } = await createSessionTranscriptBootstrap({ + reader: data.reader, + sessionId: SESSION, + subscriptionId: 'diagnosis-subscription', + throughSequence: data.throughSequence, + maxBytes: 16 * 1024, + projection: 'owner', + }); + counts.bootstrapMessages += bootstrap.durable.fragments.length; + counts.rawBytes += bootstrap.durable.rawBytes; + const handle = new ClientSessionSubscription( + { + hostEpoch: 'epoch', + subscriptionId: state.subscriptionId, + nextSequence: 1, + activeAssistantStreams: [], + transcript: bootstrap, + snapshot: { session: { sessionId: SESSION }, projectionRevision: 1 }, + }, + async () => { + counts.closed += 1; + }, + async (request) => { + counts.pageRequests += 1; + requestedDirections.add(request.direction); + if (counts.pageRequests === abortAtPage) { + await handlers.get('search:thread:cancel')(event, 'diagnosis-request'); + } + const page = await readSessionTranscriptPage({ reader: data.reader, state, request }); + assert.ok(page.rawBytes <= request.maxBytes); + counts.rawBytes += page.rawBytes; + return page; + }, + async () => {}, + ); + const decode = (value) => { + counts.decodedMessages += 1; + const message = decodeStoredMessage(value); + const text = message.text; + Object.defineProperty(message, 'text', { + enumerable: true, + get() { + counts.scannedMessageFields += 1; + counts.beforeFirstCandidate ??= { + decodedMessages: counts.decodedMessages, + pageRequests: counts.pageRequests, + }; + return text; + }, + }); + return message; + }; + return { + handle, + decode, + transcriptBootstrap: handle.transcriptBootstrap, + snapshot: handle.snapshot, + loadTranscriptPage: (input) => handle.loadTranscriptPage(input), + decodeTranscriptPage: (page, maxMessageBytes, accountAssemblyBytes) => + handle.decodeTranscriptPage(page, decode, maxMessageBytes, accountAssemblyBytes), + loadTranscript: () => handle.loadTranscript(decode), + close: () => handle.close(), + }; + }; + registerRuntimeHostSearchIpc({ + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + client: { + listSessions: async () => [catalogSession(name)], + openSession, + queryRuntimePolicy: async () => ({ revision: 1, policy: createDefaultRuntimePolicy() }), + }, + }); + + // Count actual message JSON parses separately from schema decoding. Cursor + // parsing also uses JSON.parse, so count only fixture message objects. + const originalParse = JSON.parse; + JSON.parse = function (...args) { + const result = originalParse.apply(JSON, args); + if (result?.type === 'user' && result.id?.startsWith('message-')) counts.messageJsonParses += 1; + return result; + }; + let result; + let forwardHasMore; + let pageOnlyTruncated; + try { + if (firstForwardPageOnly) { + // Feasibility probe, NOT a full streaming-search implementation. Fetch + // exactly the oldest page, then pass that page to the unchanged matcher. + const session = await openSession(); + try { + const page = await session.handle.loadTranscriptPage({ + direction: 'newer', + throughSequence: data.throughSequence, + cursor: null, + anchorSequence: null, + maxBytes: 512 * 1024, + }); + const decoded = await session.handle.decodeTranscriptPage(page, session.decode); + assert.equal(decoded.messages[0].identity, 0); + forwardHasMore = decoded.nextCursor !== null; + const response = await runThreadSearch( + { source: 'thread', query: QUERY, limit }, + { + listSessions: async () => [catalogSession(name)], + getPrivacyContext: async () => ({ incognitoActive: false }), + async *readMessagePages() { + yield { + messages: decoded.messages.map(({ identity, message }) => ({ + sequence: identity, + message, + })), + hasMore: false, + }; + }, + }, + ); + assert.ok(response.ok); + pageOnlyTruncated = response.truncated; + result = response.results; + } finally { + await session.close(); + } + } else { + result = await handlers.get('search:thread')( + event, + { source: 'thread', query: QUERY, limit }, + 'diagnosis-request', + ); + } + } finally { + JSON.parse = originalParse; + } + + assert.equal(counts.closed, counts.opened); + assert.equal(event.sender.listenerCount('destroyed'), 0); + assert.equal(event.sender.listenerCount('render-process-gone'), 0); + if (abortAtPage !== undefined) { + assert.equal(result.reason, 'aborted'); + assert.equal(counts.pageRequests, abortAtPage); + assert.equal(counts.decodedMessages, 0); + } else { + assert.ok(Array.isArray(result)); + assert.equal(result.length, titleHit ? 1 : hitIndex >= 0 ? Math.min(limit, hitCount) : 0); + if (hitIndex >= 0 && !titleHit) { + assert.equal(result[0].target.sequence, hitIndex * stride); + assert.equal(result[0].target.turnId, `turn-${hitIndex}`); + } + } + return { + label, + total, + hitIndex, + hitCount, + limit, + stride, + ...counts, + directions: [...requestedDirections], + outcome: Array.isArray(result) + ? { + results: result.length, + matchedTurnIds: result.map((hit) => hit.target.turnId ?? null), + first: result[0] ?? null, + } + : result, + ...(forwardHasMore === undefined ? {} : { forwardHasMore, pageOnlyTruncated }), + ...(hitIndex < 0 ? {} : { matchingFixtureSequence: hitIndex * stride }), + }; +} + +const results = []; +for (const total of [2, 256, 5000, 20000]) { + results.push(await runScenario({ label: `first-hit-${total}`, total })); +} +results.push(await runScenario({ label: 'first-hit-limit-10', total: 20000, limit: 10 })); +results.push( + await runScenario({ label: 'ten-early-hits-baseline', total: 20000, limit: 10, hitCount: 10 }), +); +results.push( + await runScenario({ + label: 'ten-early-hits-feasibility', + total: 20000, + limit: 10, + hitCount: 10, + firstForwardPageOnly: true, + }), +); +results.push(await runScenario({ label: 'last-hit', total: 20000, hitIndex: 19999 })); +results.push(await runScenario({ label: 'no-hit', total: 20000, hitIndex: -1 })); +results.push(await runScenario({ label: 'title-only', total: 20000, titleHit: true })); +results.push(await runScenario({ label: 'cancel-first-page', total: 20000, abortAtPage: 1 })); +results.push( + await runScenario({ label: 'oldest-page-feasibility', total: 20000, firstForwardPageOnly: true }), +); +results.push(await runScenario({ label: 'page-edge-hit-baseline', total: 20000, hitIndex: 255 })); +results.push( + await runScenario({ + label: 'page-edge-hit-feasibility', + total: 20000, + hitIndex: 255, + firstForwardPageOnly: true, + }), +); +results.push( + await runScenario({ label: 'sparse-sequence-control', total: 3, hitIndex: 1, stride: 8 }), +); +writeFileSync(join(out, 'results.json'), JSON.stringify(results, null, 2) + '\n'); +console.table( + results.map( + ({ + label, + decodedMessages, + messageJsonParses, + pageRequests, + scannedMessageFields, + rawBytes, + }) => ({ + label, + decodedMessages, + messageJsonParses, + pageRequests, + scannedMessageFields, + rawBytes, + }), + ), +); + +const baseline = results.find((row) => row.label === 'first-hit-20000'); +assert.equal(baseline.scannedMessageFields, 1); +const forward = results.find((row) => row.label === 'oldest-page-feasibility'); +assert.equal(forward.pageRequests, 1); +assert.equal(forward.decodedMessages, 256); +assert.equal(forward.forwardHasMore, true); +assert.deepEqual(forward.outcome.first.target.turnId, baseline.outcome.first.target.turnId); +assert.deepEqual( + results.find((row) => row.label === 'ten-early-hits-baseline').outcome.matchedTurnIds, + results.find((row) => row.label === 'ten-early-hits-feasibility').outcome.matchedTurnIds, +); +if (process.argv.includes('--check-budget')) { + // The result budget bounds work to the first page, independent of the + // transcript's total length. No wall-clock threshold is involved. + assert.equal(baseline.decodedMessages, 256); + assert.equal(baseline.pageRequests, 1); + assert.equal( + results.find((row) => row.label === 'page-edge-hit-baseline').outcome.first.truncated, + true, + ); +} diff --git a/docs/performance/history-search-diagnosis-2026-09-20/results.json b/docs/performance/history-search-diagnosis-2026-09-20/results.json new file mode 100644 index 0000000000..90ac9d9209 --- /dev/null +++ b/docs/performance/history-search-diagnosis-2026-09-20/results.json @@ -0,0 +1,586 @@ +[ + { + "label": "first-hit-2", + "total": 2, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 0, + "decodedMessages": 2, + "messageJsonParses": 2, + "scannedMessageFields": 1, + "bootstrapMessages": 2, + "rawBytes": 274, + "beforeFirstCandidate": { + "decodedMessages": 2, + "pageRequests": 0 + }, + "directions": [], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-256", + "total": 256, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 1, + "bootstrapMessages": 81, + "rawBytes": 51012, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["older"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-5000", + "total": 5000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 20, + "decodedMessages": 5000, + "messageJsonParses": 5000, + "scannedMessageFields": 1, + "bootstrapMessages": 80, + "rawBytes": 1016557, + "beforeFirstCandidate": { + "decodedMessages": 5000, + "pageRequests": 20 + }, + "directions": ["older"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-20000", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 78, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 1, + "bootstrapMessages": 79, + "rawBytes": 4106558, + "beforeFirstCandidate": { + "decodedMessages": 20000, + "pageRequests": 78 + }, + "directions": ["older"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "first-hit-limit-10", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 10, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 78, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 20000, + "bootstrapMessages": 79, + "rawBytes": 4106558, + "beforeFirstCandidate": { + "decodedMessages": 20000, + "pageRequests": 78 + }, + "directions": ["older"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + } + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "ten-early-hits-baseline", + "total": 20000, + "hitIndex": 0, + "hitCount": 10, + "limit": 10, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 78, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 10, + "bootstrapMessages": 79, + "rawBytes": 4105514, + "beforeFirstCandidate": { + "decodedMessages": 20000, + "pageRequests": 78 + }, + "directions": ["older"], + "outcome": { + "results": 10, + "matchedTurnIds": [ + "turn-0", + "turn-1", + "turn-2", + "turn-3", + "turn-4", + "turn-5", + "turn-6", + "turn-7", + "turn-8", + "turn-9" + ], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0 + } + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "ten-early-hits-feasibility", + "total": 20000, + "hitIndex": 0, + "hitCount": 10, + "limit": 10, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 10, + "bootstrapMessages": 79, + "rawBytes": 66321, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 10, + "matchedTurnIds": [ + "turn-0", + "turn-1", + "turn-2", + "turn-3", + "turn-4", + "turn-5", + "turn-6", + "turn-7", + "turn-8", + "turn-9" + ], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0, + "messageId": "message-0", + "matchKind": "user_message", + "messageTimestamp": 1 + } + } + }, + "forwardHasMore": true, + "pageOnlyTruncated": true, + "matchingFixtureSequence": 0 + }, + { + "label": "last-hit", + "total": 20000, + "hitIndex": 19999, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 78, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 20000, + "bootstrapMessages": 79, + "rawBytes": 4106558, + "beforeFirstCandidate": { + "decodedMessages": 20000, + "pageRequests": 78 + }, + "directions": ["older"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-19999"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-19999", + "sequence": 19999 + } + } + }, + "matchingFixtureSequence": 19999 + }, + { + "label": "no-hit", + "total": 20000, + "hitIndex": -1, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 78, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 20000, + "bootstrapMessages": 79, + "rawBytes": 4106674, + "beforeFirstCandidate": { + "decodedMessages": 20000, + "pageRequests": 78 + }, + "directions": ["older"], + "outcome": { + "results": 0, + "matchedTurnIds": [], + "first": null + } + }, + { + "label": "title-only", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 0, + "closed": 0, + "pageRequests": 0, + "decodedMessages": 0, + "messageJsonParses": 0, + "scannedMessageFields": 0, + "bootstrapMessages": 0, + "rawBytes": 0, + "beforeFirstCandidate": null, + "directions": [], + "outcome": { + "results": 1, + "matchedTurnIds": [null], + "first": { + "source": "thread", + "title": "needleunique", + "summary": "任务标题", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis" + }, + "truncated": true + } + }, + "matchingFixtureSequence": 0 + }, + { + "label": "cancel-first-page", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 0, + "messageJsonParses": 79, + "scannedMessageFields": 0, + "bootstrapMessages": 79, + "rawBytes": 69345, + "beforeFirstCandidate": null, + "directions": ["older"], + "outcome": { + "ok": false, + "reason": "aborted", + "message": "History search was aborted." + }, + "matchingFixtureSequence": 0 + }, + { + "label": "oldest-page-feasibility", + "total": 20000, + "hitIndex": 0, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 1, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-0"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-0", + "sequence": 0, + "messageId": "message-0", + "matchKind": "user_message", + "messageTimestamp": 1 + }, + "truncated": true + } + }, + "forwardHasMore": true, + "pageOnlyTruncated": true, + "matchingFixtureSequence": 0 + }, + { + "label": "page-edge-hit-baseline", + "total": 20000, + "hitIndex": 255, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 78, + "decodedMessages": 20000, + "messageJsonParses": 20000, + "scannedMessageFields": 256, + "bootstrapMessages": 79, + "rawBytes": 4106558, + "beforeFirstCandidate": { + "decodedMessages": 20000, + "pageRequests": 78 + }, + "directions": ["older"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-255"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-255", + "sequence": 255 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 255 + }, + { + "label": "page-edge-hit-feasibility", + "total": 20000, + "hitIndex": 255, + "hitCount": 1, + "limit": 1, + "stride": 1, + "opened": 1, + "closed": 1, + "pageRequests": 1, + "decodedMessages": 256, + "messageJsonParses": 256, + "scannedMessageFields": 256, + "bootstrapMessages": 79, + "rawBytes": 67365, + "beforeFirstCandidate": { + "decodedMessages": 256, + "pageRequests": 1 + }, + "directions": ["newer"], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-255"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-255", + "sequence": 255, + "messageId": "message-255", + "matchKind": "user_message", + "messageTimestamp": 256 + } + } + }, + "forwardHasMore": true, + "pageOnlyTruncated": false, + "matchingFixtureSequence": 255 + }, + { + "label": "sparse-sequence-control", + "total": 3, + "hitIndex": 1, + "hitCount": 1, + "limit": 1, + "stride": 8, + "opened": 1, + "closed": 1, + "pageRequests": 0, + "decodedMessages": 3, + "messageJsonParses": 3, + "scannedMessageFields": 2, + "bootstrapMessages": 3, + "rawBytes": 469, + "beforeFirstCandidate": { + "decodedMessages": 3, + "pageRequests": 0 + }, + "directions": [], + "outcome": { + "results": 1, + "matchedTurnIds": ["turn-1"], + "first": { + "source": "thread", + "title": "History review", + "summary": "用户消息", + "snippet": "needleunique", + "target": { + "kind": "thread", + "sessionId": "history-search-diagnosis", + "turnId": "turn-1", + "sequence": 1 + }, + "truncated": true + } + }, + "matchingFixtureSequence": 8 + } +] diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts index 7e6086e8d5..edbcb0282f 100644 --- a/packages/core/src/thread-search.ts +++ b/packages/core/src/thread-search.ts @@ -27,7 +27,7 @@ * * Scope (this module, PR-SEARCH-2): * - Pure helper. Accepts an injected `ThreadSearchDeps` so unit tests can - * supply fake `listSessions` / `readMessages` without an Electron runtime. + * supply fake `listSessions` / `readMessagePages` without an Electron runtime. * - Bounded substring scan over user-visible message types only: * UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage. * Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage / @@ -99,7 +99,15 @@ export const THREAD_SOURCE = 'thread' as const; */ export interface ThreadSearchDeps { listSessions(): Promise; - readMessages(sessionId: string, abortSignal?: AbortSignal): Promise; + /** + * Lazily reads oldest-first pages, preserving the transcript's sequence + * identities. Ending iteration must release the reader. Ordinary read + * failures throw ThreadSearchReadError so partial content hits can be undone. + */ + readMessagePages( + sessionId: string, + abortSignal?: AbortSignal, + ): AsyncIterable; /** * Host-authority workspace privacy snapshot. Returned as `unknown` * deliberately — the helper validates the payload with @@ -110,6 +118,16 @@ export interface ThreadSearchDeps { getPrivacyContext(): Promise; } +export interface ThreadSearchMessagePage { + readonly messages: readonly { readonly sequence: number; readonly message: StoredMessage }[]; + /** Lets a result at the page boundary stop without fetching the next page. */ + readonly hasMore: boolean; +} + +export class ThreadSearchReadError extends Error { + readonly name = 'ThreadSearchReadError'; +} + export interface ThreadSearchSuccess { readonly ok: true; readonly results: SearchResult[]; @@ -198,7 +216,7 @@ export async function runThreadSearch( // - 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`. + // Both paths MUST NOT touch `listSessions` / `readMessagePages`. // Distinguishing message wording is kept for diagnostics; consumers // can read `message` if they need to differentiate. const privacyPayload = await deps.getPrivacyContext(); @@ -294,67 +312,84 @@ export async function runThreadSearch( } } - 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)); + // A failed transcript contributes no content hits, even if earlier pages + // matched. The title and any previous Sessions' results remain valid. + const contentStart = results.length; + const bytesBeforeContent = totalBytes; + const truncatedBeforeContent = truncated; + let messagesScanned = 0; + try { + for await (const page of deps.readMessagePages(session.id, options.abortSignal)) { + if (options.abortSignal?.aborted) return abortedSearch(); + for (let messageIndex = 0; messageIndex < page.messages.length; messageIndex += 1) { + if (messagesScanned > 0 && messagesScanned % 256 === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + if (options.abortSignal?.aborted) return abortedSearch(); + messagesScanned += 1; + const { message, sequence } = page.messages[messageIndex]!; + const turnId = message.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; + + 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: searchableTitle, + summary: formatSearchResultSummary(message), + snippet, + target: { + kind: 'thread', + sessionId: session.id, + ...(turnId ? { turnId } : {}), + sequence, + messageId: message.id, + matchKind: threadSearchMatchKind(message), + messageTimestamp: message.ts, + }, + }); + if ( + results.length >= maxResults && + (messageIndex + 1 < page.messages.length || page.hasMore) + ) { + truncated = true; + scannedCompletePage = false; + break sessionScan; + } + } + if (!page.hasMore) break; } + } catch (error) { 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 (!(error instanceof ThreadSearchReadError)) throw error; + results.length = contentStart; + totalBytes = bytesBeforeContent; + truncated = truncatedBeforeContent; + scannedCompletePage = true; } } + if (options.abortSignal?.aborted) return abortedSearch(); if (truncated && results.length > 0) { results[results.length - 1] = { ...results[results.length - 1]!, truncated: true }; diff --git a/packages/ui/src/__tests__/search-modal-source.test.ts b/packages/ui/src/__tests__/search-modal-source.test.ts index 64281c9e08..c0eb5d849b 100644 --- a/packages/ui/src/__tests__/search-modal-source.test.ts +++ b/packages/ui/src/__tests__/search-modal-source.test.ts @@ -162,7 +162,7 @@ describe('search error copy', () => { const fail = async (): Promise => assert.fail('Rejected queries must not read history'); const response = await runThreadSearch(request, { listSessions: fail, - readMessages: fail, + readMessagePages: () => assert.fail('Rejected queries must not read history'), getPrivacyContext: fail, }); assert.equal(response.ok, false); From edf292dd5f32dd9ca024eed739d57bac14b08425 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Sun, 20 Sep 2026 14:07:59 +0800 Subject: [PATCH 2/2] chore: remove history search diagnostic artifacts Keep the implementation and regression tests in the pull request. Generated-by: Codex --- ...story-search-diagnosis-2026-09-20.zh-CN.md | 143 ----- .../build.mjs | 113 ---- .../implementation-manifest.json | 17 - .../implementation-results.json | 586 ------------------ .../manifest.json | 17 - .../probe.mjs | 415 ------------- .../results.json | 586 ------------------ 7 files changed, 1877 deletions(-) delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20/build.mjs delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20/manifest.json delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20/probe.mjs delete mode 100644 docs/performance/history-search-diagnosis-2026-09-20/results.json diff --git a/docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md b/docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md deleted file mode 100644 index 97935b18e9..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20.zh-CN.md +++ /dev/null @@ -1,143 +0,0 @@ - - -**实现更新(2026-09-20):** 历史搜索已改为消费正序 `readMessagePages`,达到结果预算后立即结束迭代。完整小会话复用 bootstrap;其他会话固定打开时的 watermark,从最早位置读起。每条结果保留源 sequence,普通读取失败撤销本 Session 的正文命中,取消和 reader 关闭语义保留。 - -修复后的同夹具结果见 [implementation-results.json](./history-search-diagnosis-2026-09-20/implementation-results.json):首条命中、limit=1 时,解码 **20,000 → 256**、额外分页 **78 → 1**;前十条命中、limit=10 得到相同改善。页末命中保留 `truncated=true`。无命中仍需全扫,因保留尾部 bootstrap,当前该夹具额外分页为 79 次、原始字节约多 16KiB。下文保留实现前的诊断与基线。 - -**实现验证:** 新增真实 IPC → 搜索器 → subscription → Host pager/decoder 的分页回归测试,先观察到 `20000 !== 256` 失败,再验证修复通过。Desktop 搜索、分页、IPC 共 40 项测试和 UI 搜索相关 6 项测试通过;Core、Desktop、UI 类型检查通过。代码规范与需求审查均未发现实现问题,需求审查另以 8,000 个确定性场景对比新旧结果、截断及 continuation 语义。新版探针的 `--check-budget` 通过;[implementation-manifest.json](./history-search-diagnosis-2026-09-20/implementation-manifest.json) 记录修复后源文件哈希,其 commit 字段为测量时的基线 HEAD。 - -全仓 `npm test` 已完整运行,构建成功,测试未全绿。Shell PATH、执行器取消、peer invitation 用例独立重跑通过;Storage 子进程就绪检查受 SQLite ExperimentalWarning 干扰,使用 `NODE_NO_WARNINGS=1` 重跑通过;Eval 默认 Python 3.9 不兼容,改用 Python 3.12 后 87 项测试通过(12 项跳过)。Runtime Host 的 `production Host publishes and retires an implementation child patch` 独立重跑仍报 `Hosted real-model Turn did not become terminal`;该用例不经过本次搜索路径,留待单独排查。 - -发布前补验:全局 `npm run lint`、`npm run build`、`npm run typecheck` 及 Desktop/UI 两项 Knip 检查通过。`npm run format:check` 因工作区原有的 16 个未跟踪文件失败;限定全部 Git 跟踪文件的同一 Biome formatter 检查通过,本次变更的格式及提交检查均通过。问题已单独提交为 [Issue #5523](https://github.com/apache/maka/issues/5523),关联 #2913 / #4677,不关闭这两个范围更大的跟踪项。 - -**实现前诊断与基线(以下保留原始记录)** - -**根因是搜索的数据接口要求完整消息数组:结果数量限制只能停止匹配,无法提前停止拉页和解码。** 基线源码稳定复现“limit=1、最早一条消息命中,仍解码 20,000 条并追加请求 78 页”。现有取消有效,分页协议也已经支持正向读取;应调整搜索消费消息的方式。 - -原始诊断仅新增本文、探针和结果。源码基线为 `d3292393c575d2019ca406a5048099abf3399e0f`,Node v24.14.0 / macOS arm64。原始问题来自本地性能审计 `latest-code-audit-2026-09-19.zh-CN.md` 第 4 项。[manifest.json](./history-search-diagnosis-2026-09-20/manifest.json) 记录关键源码 SHA-256。 - -**可复现的证据** - -扩展探针经过真实 Desktop `search:thread` handler、`runThreadSearch`、`ClientSessionSubscription`、`decodeStoredMessage` 和 Host pager。只有 transcript storage 与传输使用合成实现;每条消息独立 Turn,符合 16KiB bootstrap、512KiB / 256 条分页上限。直接打包 TypeScript 源码及工作区依赖,不读取旧 dist。 - -| 场景 | 消息数 | 结果 limit | 完整消息解码 | bootstrap 之外的请求 | 检查正文字段次数 | -| --- | ---: | ---: | ---: | ---: | ---: | -| 最早消息命中 | 2 | 1 | 2 | 0 | 1 | -| 最早消息命中 | 256 | 1 | 256 | 1 | 1 | -| 最早消息命中 | 5,000 | 1 | 5,000 | 20 | 1 | -| 最早消息命中 | 20,000 | 1 | 20,000 | 78 | 1 | -| 只有最早消息命中 | 20,000 | 10 | 20,000 | 78 | 20,000 | -| 最新消息命中 | 20,000 | 1 | 20,000 | 78 | 20,000 | -| 无命中 | 20,000 | 1 | 20,000 | 78 | 20,000 | -| 标题命中 | 20,000 | 1 | 0 | 0 | 0 | - -计数重复运行一致,详见 [results.json](./history-search-diagnosis-2026-09-20/results.json)。读取第一条候选正文时,探针已记录 `decodedMessages=20000, pageRequests=78`。完整 JSON 解析数也是 20,000,不只是最后一次 schema decoder 的调用数。2 条消息就能复现多余解码;256 条已跨过 bootstrap,可以复现多余分页。 - -20,000 条夹具的 bootstrap 包含最新 79 条,剩余 19,921 条需要 `ceil(19921 / 256) = 78` 次顺序请求。原始消息字节合计 4,106,558(约 3.92MiB),不包含 base64 膨胀、协议 JSON、Session snapshot 或帧开销。真实大消息、同 Turn 分页边界会改变页数,78 不是固定常数。 - -原审计探针在本机重跑约 13–14ms,但不含真实网络、磁盘、Host RuntimeEvent 投影和 UI 绘制;扩展探针还增加了计数与真实 schema decoder,不能把两者耗时作为 A/B。可靠证据是请求量、解码量与匹配起点。若每个分页实际耗时 R,当前 78 个顺序请求还会贡献约 `78 × R` 的等待,这只是成本表达式,不是实测端到端延迟。 - -**调用链与根因** - -```mermaid -flowchart TD - A[搜索请求 limit=1] --> B[校验隐私、排序 Session、匹配标题] - B --> C[readMessages 要求完整数组] - C --> D[openSession:最新 16KiB bootstrap] - D --> E[loadTranscript:older 方向逐页读到 cursor=null] - E --> F[解析全部消息、反转为正序、schema 解码] - F --> G[从最早消息开始匹配] - G --> H[首条命中,下一次循环因 limit 停止] -``` - -1. [ThreadSearchDeps.readMessages](../../packages/core/src/thread-search.ts#L100) 返回 `Promise`。消费方必须等整个 Promise 完成,无法在某条命中后让生产方停下。 -2. [Desktop adapter](../../apps/desktop/src/main/runtime-host-search-ipc-main.ts#L75) 每次为搜索打开独立 Session,调用 `session.loadTranscript()`,最后关闭。`limit` 未传入此层;加载缓存仅属于本次 handle,下一次查询会新建 handle。 -3. [openSession](../../apps/desktop/src/main/runtime-host-client.ts#L1648) 请求 `transcript: { kind: 'tail', maxBytes: 16KiB }`。[loadTranscriptSource](../../packages/runtime-host/src/client/session-subscription.ts#L325) 只以 `cursor === null` 作为正常读完条件,逐页 `await`,并保留全部解码对象。 -4. assembler 在 [completeCurrent](../../packages/runtime-host/src/client/session-subscription.ts#L657) 解析每条完整 JSON,在 [finish](../../packages/runtime-host/src/client/session-subscription.ts#L568) 将倒序结果反转;外层 [loadTranscript](../../packages/runtime-host/src/client/session-subscription.ts#L214) 再执行 `messages.map(decodeMessage)`。 -5. [runThreadSearch](../../packages/core/src/thread-search.ts#L297) 拿到完整数组后才逐条匹配;第 307 行的 `maxResults` 检查确实能停止后续匹配,但此时读取和解码已经完成。 - -因此,当前命中发生在第 k 条、仅需少量结果时,单 Session 的加载仍为全量消息/字节成本,匹配才按 k 停止。保存全部消息对象使加载侧内存也随会话正文增长。`MAX_SESSIONS_SCANNED=200`、结果上限、snippet 字节上限都不能约束一段长会话的读取量。 - -方向是第二个约束:**Session 按 lastMessageAt 从新到旧排序,同一 Session 内按消息从旧到新匹配**,且该 Session 的标题先于正文。当前 bootstrap 从尾部出发;不能直接沿旧的 `older` cursor 逐页命中就停,否则会改变结果顺序,且最早消息仍要等最后一页。 - -**已排除的原因与适用范围** - -- 不是 `limit` 完全失效:同一夹具 limit=1 只检查 1 条正文,limit=10 且仅 1 个命中时检查 20,000 条;两者都提前加载全量。 -- 不是取消缺失:[adapter](../../apps/desktop/src/main/runtime-host-search-ipc-main.ts#L79) 把 AbortSignal 接到本次 handle.close,subscription 在在途页返回后再次检查关闭状态。探针在第一次追加请求时取消:只发出 1 页、handle 只关闭一次,返回 `aborted`;bootstrap 的 79 条 JSON 已解析,但没有继续 schema 解码或匹配。取消不能撤回已经发出的那一页,也不代表能打断一段正在执行的同步解码。 -- 标题已满足 limit 时不会打开正文 Session,属于现有有效优化。 -- [搜索框实际请求 limit=10](../../packages/ui/src/search-modal.tsx#L101)。limit=1 是最小反例;真实界面需要较早找到 10 条才有同类提前停止收益。无命中、只有少量命中或命中很晚,仍可能扫描全段。 -- [多 Host 汇总](../../apps/desktop/src/preload/multi-host-thread-search.ts#L84) 等所有 Host 完成后再截断结果,各 Host 内仍走上述链路。这会放大等待,但不是本次 78 页的来源。 - -**已验证的修复方向** - -现有 `session.transcript.page` 支持 `direction: 'newer'`,无需新增协议即可从最早位置开始读。以打开时的 watermark 固定本次扫描: - -```ts -await session.loadTranscriptPage({ - direction: 'newer', - throughSequence: session.transcriptBootstrap.durable.throughSequence, - cursor: null, - anchorSequence: null, - maxBytes: 512 * 1024, -}); -``` - -[Host pager](../../packages/runtime-host/src/server/session-transcript-pager.ts#L193) 对该请求从 position=0 开始。`anchorSequence: 0` 意味着从它之后开始,会跳过 sequence=0,应使用 null。后续只使用同 subscription、方向、watermark 对应的 cursor。 - -隔离实验保留现有打开 Session 的尾部 bootstrap,随后正向读取最早一页,将这页交给**未经修改的匹配器**: - -| limit=1、最早消息命中 | 当前完整加载 | 仅正向首页的可行性实验 | -| --- | ---: | ---: | -| 额外分页请求 | 78 | 1 | -| 完整消息解码 | 20,000 | 256 | -| 原始消息字节,含 bootstrap | 4,106,558 | 67,365 | -| 首个命中 Turn / 消息位置 | turn-0 / 0 | turn-0 / 0 | -| 未读取的历史仍存在 | 否 | 是,cursor 非空 | - -前十条消息均命中、limit=10 的对照也保持全部十个 Turn 的顺序,额外分页请求同样由 78 降为 1,解码由 20,000 降为 256。这验证了现有协议、assembler、匹配逻辑的组合可行性,**没有实现通用的逐页搜索,也不是生产修复后的性能数据**。实验仍支付一次尾部 bootstrap 的 I/O,但不解码它;小会话已在 bootstrap 完整呈现时,正式实现可直接复用。 - -建议的改动集中在 Core 搜索依赖与 Desktop adapter:让搜索器按需拉取正序消息页或消费惰性 `AsyncIterable`;每页保留“是否还有后续内容”,每条保留身份;达到结果预算就结束迭代并在 finally 关闭专用 handle。复用已有 `loadTranscriptPage` / `decodeTranscriptPage`,让正文加载成本随已扫描前缀及当前页增长,而不是总会话长度。分页大小决定最多多读一页的成本,不应把结果 limit 直接当成消息条数上限。 - -当前页的最后一条消息如果跨页,`decodeTranscriptPage` 会继续拉取分片来完成它。因此“只读一页、最多 256 条”是本夹具完整小消息下的数字;真实字节上界还需考虑单消息装配预算及 Host Turn 投影成本。 - -**实现时需要明确的边界** - -- **全局预算与截断标记。** 不能简单对每页独立运行旧函数再拼接:结果数、总 snippet 字节、Session 扫描预算、标题优先级都要跨页保留。探针把唯一命中放在最早页第 256 条:原路径标记 `truncated=true`,只把首页作为完整数组的实验得到 `false`,但 Host cursor 明明还有下一页。应把页的 hasMore 纳入判断,且达到 limit 后在调用迭代器 next 之前停止,避免恰在页尾时又拉一页。 -- **两种 cursor 不可混用。** Core 的 `nextCursor` 目前用于扫完 200 个 Session 后继续,绑定 query、Session ID 和时间;Host cursor 是本 subscription 的消息位置,绑定方向与 watermark。不能在关闭 handle 后把 Host cursor 作为 Core 对外 continuation 返回。 -- **正文身份与数组下标。** 当前结果写 `sequence: messageIndex`,完整加载时已丢弃 assembler.identity。合成稀疏 sequence `[0,8,16]` 中第二条命中,现有返回 sequence=1,源 identity=8;真实 reader 也以 `ordinal * 8 + offset` 生成可能稀疏的坐标。逐页后不能把每页下标归零,也不能将数组下标当 Host cursor/anchor。应保留真实 identity,并明确现有结果字段的兼容语义。当前 Renderer [按 turnId 查 Turn 索引定位](../../apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts#L145),本次未证明用户点击会跳错位置。 -- **取消、错误和过滤。** 保留 requestId / 窗口退出取消、隐私前置校验、凭证查询拒绝、先脱敏再匹配、排除 thinking / 系统消息,以及归档与 revision 过滤。当前 adapter 读失败时整个 Session 正文返回 null;逐页累积后若后页失败,要明确是否回滚本 Session 已收集的内容结果,以保持原语义。 -- **没有足够命中的最坏情况。** 增量消费消除可避免的全量预加载,不会让无索引的任意子串搜索在无命中时变成常数成本。若还要优化这一类查询,应另评估 Host 侧搜索或搜索投影;这会涉及存储与匹配语义,不是本次小范围改动的必要前提。 - -**验证入口** - -现有 Core 单测通常直接提供完整内存数组,IPC 测试也多数 mock `loadTranscript()`,能验证结果与取消,却看不到真实分页读取量。本次探针补上 handler → matcher → pager / assembler 这一组合边界;正式修复应在此类边界用请求数与解码数做回归断言,而不只断言结果条数。 - -验收应覆盖:最早命中且仍有多页、界面 limit=10 的前十条命中、命中位于页末、跨消息分片、最新命中与无命中、标题先满足预算、在途页取消、稀疏 identity,以及分页失败/隐私过滤。保留现有 Session 顺序与会话内正序结果。 - -从仓库根目录运行: - -```sh -search_diag_tmp=$(mktemp -d "${TMPDIR:-/tmp}/maka-history-search.XXXXXX") -node docs/performance/history-search-diagnosis-2026-09-20/build.mjs "$search_diag_tmp" -node docs/performance/history-search-diagnosis-2026-09-20/probe.mjs "$search_diag_tmp" -node docs/performance/history-search-diagnosis-2026-09-20/probe.mjs "$search_diag_tmp" --check-budget -``` - -普通运行验证夹具、命中、身份、关闭次数及正向首页能力。诊断时的 `--check-budget` 以 `decoded 20000/20000, requested 78 more pages` 失败;实现后脚本已迁移到新接口,现应通过“只解码 256 条、追加请求 1 页、页末保留截断标记”的断言。历史 [results.json](./history-search-diagnosis-2026-09-20/results.json) 保留原始基线,新的测量独立保存。 diff --git a/docs/performance/history-search-diagnosis-2026-09-20/build.mjs b/docs/performance/history-search-diagnosis-2026-09-20/build.mjs deleted file mode 100644 index 42b408283d..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20/build.mjs +++ /dev/null @@ -1,113 +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 { build } from 'esbuild'; -import { createHash } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -if (!process.argv[2]) throw new Error('Pass a temporary output directory'); -const repo = process.cwd(); -const out = resolve(process.argv[2]); -mkdirSync(out, { recursive: true }); -if (!existsSync(join(out, 'node_modules'))) { - symlinkSync(join(repo, 'node_modules'), join(out, 'node_modules'), 'dir'); -} -const exports = [ - ['ClientSessionSubscription', 'packages/runtime-host/src/client/session-subscription.ts'], - ['runThreadSearch', 'packages/core/src/thread-search.ts'], - ['decodeStoredMessage', 'packages/core/src/session.ts'], - ['createDefaultRuntimePolicy', 'packages/core/src/runtime-policy.ts'], - ['registerRuntimeHostSearchIpc', 'apps/desktop/src/main/runtime-host-search-ipc-main.ts'], - [ - 'createSessionTranscriptBootstrap, readSessionTranscriptPage', - 'packages/runtime-host/src/server/session-transcript-pager.ts', - ], -]; -await build({ - stdin: { - contents: exports - .map(([names, path]) => `export { ${names} } from ${JSON.stringify(join(repo, path))};`) - .join('\n'), - loader: 'ts', - resolveDir: repo, - sourcefile: 'history-search-diagnosis.ts', - }, - outfile: join(out, 'source.mjs'), - bundle: true, - platform: 'node', - format: 'esm', - packages: 'external', - sourcemap: 'inline', - banner: { - js: "import { createRequire as __diagnosisRequire } from 'node:module'; const require = __diagnosisRequire(import.meta.url);", - }, - plugins: [ - { - name: 'workspace-sources', - setup(builder) { - builder.onResolve({ filter: /^@maka\// }, ({ path }) => { - const [, name, ...rest] = path.split('/'); - const base = join(repo, 'packages', name); - const pkg = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')); - const exported = pkg.exports[rest.length ? `./${rest.join('/')}` : '.']; - if (typeof exported !== 'string') throw new Error(`Unsupported export ${path}`); - const target = resolve( - base, - exported.replace(/^\.\/dist\//, './src/').replace(/\.js$/, '.ts'), - ); - if (existsSync(target)) return { path: target }; - if (existsSync(`${target}x`)) return { path: `${target}x` }; - throw new Error(`Missing source ${target}`); - }); - }, - }, - ], -}); -const files = [ - ...new Set([ - ...exports.map(([, path]) => path), - 'apps/desktop/src/main/runtime-host-client.ts', - 'packages/runtime-host/src/protocol/session-transcript.ts', - 'packages/runtime-host/src/server/session-transcript-reader.ts', - ]), -]; -writeFileSync( - join(out, 'manifest.json'), - JSON.stringify( - { - commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), - node: process.version, - platform: process.platform, - arch: process.arch, - sourceSha256: Object.fromEntries( - files.map((path) => [ - path, - createHash('sha256') - .update(readFileSync(join(repo, path))) - .digest('hex'), - ]), - ), - }, - null, - 2, - ) + '\n', -); -console.log(join(out, 'source.mjs')); diff --git a/docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json b/docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json deleted file mode 100644 index e12eb29b25..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20/implementation-manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "commit": "d3292393c575d2019ca406a5048099abf3399e0f", - "node": "v24.14.0", - "platform": "darwin", - "arch": "arm64", - "sourceSha256": { - "packages/runtime-host/src/client/session-subscription.ts": "b24285e94747d4831ff7850be148adf628f33b95a6c7bfba490151741cb26034", - "packages/core/src/thread-search.ts": "4f57aee6c89116ae1f71a5525dfb15597324b18b9e9664304d91892fb4adc8f1", - "packages/core/src/session.ts": "609d31188b2ea61a964b76dcd4f5f06a1ee3fe7d73a70e474e437a9c38fda725", - "packages/core/src/runtime-policy.ts": "9fb3410f0acda7cf6817d708d354b63acc957760e05d32e8a583c276a0816040", - "apps/desktop/src/main/runtime-host-search-ipc-main.ts": "c53dbe5a14d88c58d4805125f1b3d9bdd121d5a52ef480b9f7060c82adb5c8c1", - "packages/runtime-host/src/server/session-transcript-pager.ts": "6504758f52a7e9b10f4c22f56ab296394b3ac1e71f871ca9f945b96eec0501a0", - "apps/desktop/src/main/runtime-host-client.ts": "1e8d603e232fab7315321cbd5629e1efa1699dfcb060d015b83529858165d550", - "packages/runtime-host/src/protocol/session-transcript.ts": "91ee66c382f0195fbed57e0a1b5e38dd49f0d6d965349a4baf4cf21a151f3161", - "packages/runtime-host/src/server/session-transcript-reader.ts": "2d9106a94d6b561140bcbbc3d5b257e8e981950aa09bd28d98fd584e1191d7e8" - } -} diff --git a/docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json b/docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json deleted file mode 100644 index 80c0d94520..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20/implementation-results.json +++ /dev/null @@ -1,586 +0,0 @@ -[ - { - "label": "first-hit-2", - "total": 2, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 0, - "decodedMessages": 2, - "messageJsonParses": 2, - "scannedMessageFields": 1, - "bootstrapMessages": 2, - "rawBytes": 274, - "beforeFirstCandidate": { - "decodedMessages": 2, - "pageRequests": 0 - }, - "directions": [], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-256", - "total": 256, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 1, - "bootstrapMessages": 81, - "rawBytes": 67293, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-5000", - "total": 5000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 1, - "bootstrapMessages": 80, - "rawBytes": 67332, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-20000", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 1, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-limit-10", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 10, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 79, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 20000, - "bootstrapMessages": 79, - "rawBytes": 4122911, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - } - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "ten-early-hits-baseline", - "total": 20000, - "hitIndex": 0, - "hitCount": 10, - "limit": 10, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 10, - "bootstrapMessages": 79, - "rawBytes": 66321, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 10, - "matchedTurnIds": [ - "turn-0", - "turn-1", - "turn-2", - "turn-3", - "turn-4", - "turn-5", - "turn-6", - "turn-7", - "turn-8", - "turn-9" - ], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - } - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "ten-early-hits-feasibility", - "total": 20000, - "hitIndex": 0, - "hitCount": 10, - "limit": 10, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 10, - "bootstrapMessages": 79, - "rawBytes": 66321, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 10, - "matchedTurnIds": [ - "turn-0", - "turn-1", - "turn-2", - "turn-3", - "turn-4", - "turn-5", - "turn-6", - "turn-7", - "turn-8", - "turn-9" - ], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0, - "messageId": "message-0", - "matchKind": "user_message", - "messageTimestamp": 1 - } - } - }, - "forwardHasMore": true, - "pageOnlyTruncated": true, - "matchingFixtureSequence": 0 - }, - { - "label": "last-hit", - "total": 20000, - "hitIndex": 19999, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 79, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 20000, - "bootstrapMessages": 79, - "rawBytes": 4122795, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-19999"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-19999", - "sequence": 19999 - } - } - }, - "matchingFixtureSequence": 19999 - }, - { - "label": "no-hit", - "total": 20000, - "hitIndex": -1, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 79, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 20000, - "bootstrapMessages": 79, - "rawBytes": 4123027, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 0, - "matchedTurnIds": [], - "first": null - } - }, - { - "label": "title-only", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 0, - "closed": 0, - "pageRequests": 0, - "decodedMessages": 0, - "messageJsonParses": 0, - "scannedMessageFields": 0, - "bootstrapMessages": 0, - "rawBytes": 0, - "beforeFirstCandidate": null, - "directions": [], - "outcome": { - "results": 1, - "matchedTurnIds": [null], - "first": { - "source": "thread", - "title": "needleunique", - "summary": "任务标题", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis" - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "cancel-first-page", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 0, - "messageJsonParses": 0, - "scannedMessageFields": 0, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": null, - "directions": ["newer"], - "outcome": { - "ok": false, - "reason": "aborted", - "message": "History search was aborted." - }, - "matchingFixtureSequence": 0 - }, - { - "label": "oldest-page-feasibility", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 1, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0, - "messageId": "message-0", - "matchKind": "user_message", - "messageTimestamp": 1 - }, - "truncated": true - } - }, - "forwardHasMore": true, - "pageOnlyTruncated": true, - "matchingFixtureSequence": 0 - }, - { - "label": "page-edge-hit-baseline", - "total": 20000, - "hitIndex": 255, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 256, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-255"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-255", - "sequence": 255 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 255 - }, - { - "label": "page-edge-hit-feasibility", - "total": 20000, - "hitIndex": 255, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 256, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-255"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-255", - "sequence": 255, - "messageId": "message-255", - "matchKind": "user_message", - "messageTimestamp": 256 - } - } - }, - "forwardHasMore": true, - "pageOnlyTruncated": false, - "matchingFixtureSequence": 255 - }, - { - "label": "sparse-sequence-control", - "total": 3, - "hitIndex": 1, - "hitCount": 1, - "limit": 1, - "stride": 8, - "opened": 1, - "closed": 1, - "pageRequests": 0, - "decodedMessages": 3, - "messageJsonParses": 3, - "scannedMessageFields": 2, - "bootstrapMessages": 3, - "rawBytes": 469, - "beforeFirstCandidate": { - "decodedMessages": 3, - "pageRequests": 0 - }, - "directions": [], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-1"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-1", - "sequence": 8 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 8 - } -] diff --git a/docs/performance/history-search-diagnosis-2026-09-20/manifest.json b/docs/performance/history-search-diagnosis-2026-09-20/manifest.json deleted file mode 100644 index 566004f540..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "commit": "d3292393c575d2019ca406a5048099abf3399e0f", - "node": "v24.14.0", - "platform": "darwin", - "arch": "arm64", - "sourceSha256": { - "packages/runtime-host/src/client/session-subscription.ts": "b24285e94747d4831ff7850be148adf628f33b95a6c7bfba490151741cb26034", - "packages/core/src/thread-search.ts": "085f10a0a352d991a09f60e17833161a96d5c7cbaef5fcda559a5c466c5f72b5", - "packages/core/src/session.ts": "609d31188b2ea61a964b76dcd4f5f06a1ee3fe7d73a70e474e437a9c38fda725", - "packages/core/src/runtime-policy.ts": "9fb3410f0acda7cf6817d708d354b63acc957760e05d32e8a583c276a0816040", - "apps/desktop/src/main/runtime-host-search-ipc-main.ts": "0e2101bdb635e299b2ac86a1d4600020f2e40e217c7724aa358875be110fefbf", - "packages/runtime-host/src/server/session-transcript-pager.ts": "6504758f52a7e9b10f4c22f56ab296394b3ac1e71f871ca9f945b96eec0501a0", - "apps/desktop/src/main/runtime-host-client.ts": "1e8d603e232fab7315321cbd5629e1efa1699dfcb060d015b83529858165d550", - "packages/runtime-host/src/protocol/session-transcript.ts": "91ee66c382f0195fbed57e0a1b5e38dd49f0d6d965349a4baf4cf21a151f3161", - "packages/runtime-host/src/server/session-transcript-reader.ts": "2d9106a94d6b561140bcbbc3d5b257e8e981950aa09bd28d98fd584e1191d7e8" - } -} diff --git a/docs/performance/history-search-diagnosis-2026-09-20/probe.mjs b/docs/performance/history-search-diagnosis-2026-09-20/probe.mjs deleted file mode 100644 index a15275dd44..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20/probe.mjs +++ /dev/null @@ -1,415 +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. - */ - -// Diagnostic only. Real IPC handler, matcher, subscription assembler and Host -// pager; synthetic transcript storage and transport. No database/UI latency. -import assert from 'node:assert/strict'; -import { EventEmitter } from 'node:events'; -import { writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -if (!process.argv[2]) throw new Error('Pass the temporary bundle directory'); -const out = resolve(process.argv[2]); -const { - ClientSessionSubscription, - runThreadSearch, - decodeStoredMessage, - createDefaultRuntimePolicy, - registerRuntimeHostSearchIpc, - createSessionTranscriptBootstrap, - readSessionTranscriptPage, -} = await import(pathToFileURL(join(out, 'source.mjs')).href); -const QUERY = 'needleunique'; -const SESSION = 'history-search-diagnosis'; - -function catalogSession(name) { - return { - id: SESSION, - revision: 1, - workspace: { target: { kind: 'host_path', path: '/fixture' }, hostCwd: '/fixture' }, - createdAt: 1, - activityAt: 1, - lastMessageAt: 1, - name, - isFlagged: false, - isArchived: false, - labels: [], - labelsTruncated: false, - hasUnread: false, - status: 'active', - backend: 'ai-sdk', - llmConnectionId: 'fixture', - llmConnectionSlug: 'fixture', - connectionLocked: true, - model: 'fixture', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - }; -} - -function fixture(total, hitIndex, stride = 1, hitCount = 1) { - const rows = Array.from({ length: total }, (_, index) => { - const bytes = Buffer.from( - JSON.stringify({ - type: 'user', - id: `message-${index}`, - turnId: `turn-${index}`, - ts: index + 1, - text: - hitIndex >= 0 && index >= hitIndex && index < hitIndex + hitCount - ? QUERY - : 'ordinary output '.repeat(8), - }), - ); - return { sequence: index * stride, bytes }; - }); - const throughSequence = rows.at(-1)?.sequence ?? null; - const reader = { - async readDurablePage(sessionId, request) { - assert.equal(sessionId, SESSION); - const older = request.direction === 'older'; - const position = request.position ?? (older ? throughSequence : 0); - let index = older ? Math.floor(position / stride) : Math.ceil(position / stride); - const fragments = []; - let rawBytes = 0; - let next = null; - while (index >= 0 && index < rows.length) { - const { sequence, bytes } = rows[index]; - assert.ok(sequence <= request.throughSequence); - const edge = - sequence === request.position && request.byteOffset !== undefined - ? request.byteOffset - : older - ? bytes.length - : 0; - const remaining = older ? edge : bytes.length - edge; - if ( - fragments.length === request.maxMessages || - rawBytes === request.maxBytes || - (fragments.length > 0 && rawBytes + remaining > request.maxBytes) - ) { - next = { position: sequence, byteOffset: null }; - break; - } - const size = Math.min(remaining, request.maxBytes - rawBytes); - const byteOffset = older ? edge - size : edge; - fragments.push({ - sequence, - byteOffset, - totalBytes: bytes.length, - payloadDigest: null, - data: bytes.subarray(byteOffset, byteOffset + size), - }); - rawBytes += size; - if (size < remaining) { - next = { position: sequence, byteOffset: older ? byteOffset : byteOffset + size }; - break; - } - index += older ? -1 : 1; - } - return { - throughSequence: request.throughSequence, - fragments, - rawBytes, - next, - endsAtTurnBoundary: next?.byteOffset == null, - }; - }, - }; - return { reader, throughSequence }; -} - -async function runScenario({ - label, - total, - hitIndex = 0, - limit = 1, - titleHit = false, - abortAtPage, - firstForwardPageOnly = false, - stride = 1, - hitCount = 1, -}) { - const data = fixture(total, hitIndex, stride, hitCount); - const counts = { - opened: 0, - closed: 0, - pageRequests: 0, - decodedMessages: 0, - messageJsonParses: 0, - scannedMessageFields: 0, - bootstrapMessages: 0, - rawBytes: 0, - beforeFirstCandidate: null, - }; - const requestedDirections = new Set(); - const handlers = new Map(); - const event = { sender: new EventEmitter() }; - const name = titleHit ? QUERY : 'History review'; - const openSession = async () => { - counts.opened += 1; - const { bootstrap, state } = await createSessionTranscriptBootstrap({ - reader: data.reader, - sessionId: SESSION, - subscriptionId: 'diagnosis-subscription', - throughSequence: data.throughSequence, - maxBytes: 16 * 1024, - projection: 'owner', - }); - counts.bootstrapMessages += bootstrap.durable.fragments.length; - counts.rawBytes += bootstrap.durable.rawBytes; - const handle = new ClientSessionSubscription( - { - hostEpoch: 'epoch', - subscriptionId: state.subscriptionId, - nextSequence: 1, - activeAssistantStreams: [], - transcript: bootstrap, - snapshot: { session: { sessionId: SESSION }, projectionRevision: 1 }, - }, - async () => { - counts.closed += 1; - }, - async (request) => { - counts.pageRequests += 1; - requestedDirections.add(request.direction); - if (counts.pageRequests === abortAtPage) { - await handlers.get('search:thread:cancel')(event, 'diagnosis-request'); - } - const page = await readSessionTranscriptPage({ reader: data.reader, state, request }); - assert.ok(page.rawBytes <= request.maxBytes); - counts.rawBytes += page.rawBytes; - return page; - }, - async () => {}, - ); - const decode = (value) => { - counts.decodedMessages += 1; - const message = decodeStoredMessage(value); - const text = message.text; - Object.defineProperty(message, 'text', { - enumerable: true, - get() { - counts.scannedMessageFields += 1; - counts.beforeFirstCandidate ??= { - decodedMessages: counts.decodedMessages, - pageRequests: counts.pageRequests, - }; - return text; - }, - }); - return message; - }; - return { - handle, - decode, - transcriptBootstrap: handle.transcriptBootstrap, - snapshot: handle.snapshot, - loadTranscriptPage: (input) => handle.loadTranscriptPage(input), - decodeTranscriptPage: (page, maxMessageBytes, accountAssemblyBytes) => - handle.decodeTranscriptPage(page, decode, maxMessageBytes, accountAssemblyBytes), - loadTranscript: () => handle.loadTranscript(decode), - close: () => handle.close(), - }; - }; - registerRuntimeHostSearchIpc({ - ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, - client: { - listSessions: async () => [catalogSession(name)], - openSession, - queryRuntimePolicy: async () => ({ revision: 1, policy: createDefaultRuntimePolicy() }), - }, - }); - - // Count actual message JSON parses separately from schema decoding. Cursor - // parsing also uses JSON.parse, so count only fixture message objects. - const originalParse = JSON.parse; - JSON.parse = function (...args) { - const result = originalParse.apply(JSON, args); - if (result?.type === 'user' && result.id?.startsWith('message-')) counts.messageJsonParses += 1; - return result; - }; - let result; - let forwardHasMore; - let pageOnlyTruncated; - try { - if (firstForwardPageOnly) { - // Feasibility probe, NOT a full streaming-search implementation. Fetch - // exactly the oldest page, then pass that page to the unchanged matcher. - const session = await openSession(); - try { - const page = await session.handle.loadTranscriptPage({ - direction: 'newer', - throughSequence: data.throughSequence, - cursor: null, - anchorSequence: null, - maxBytes: 512 * 1024, - }); - const decoded = await session.handle.decodeTranscriptPage(page, session.decode); - assert.equal(decoded.messages[0].identity, 0); - forwardHasMore = decoded.nextCursor !== null; - const response = await runThreadSearch( - { source: 'thread', query: QUERY, limit }, - { - listSessions: async () => [catalogSession(name)], - getPrivacyContext: async () => ({ incognitoActive: false }), - async *readMessagePages() { - yield { - messages: decoded.messages.map(({ identity, message }) => ({ - sequence: identity, - message, - })), - hasMore: false, - }; - }, - }, - ); - assert.ok(response.ok); - pageOnlyTruncated = response.truncated; - result = response.results; - } finally { - await session.close(); - } - } else { - result = await handlers.get('search:thread')( - event, - { source: 'thread', query: QUERY, limit }, - 'diagnosis-request', - ); - } - } finally { - JSON.parse = originalParse; - } - - assert.equal(counts.closed, counts.opened); - assert.equal(event.sender.listenerCount('destroyed'), 0); - assert.equal(event.sender.listenerCount('render-process-gone'), 0); - if (abortAtPage !== undefined) { - assert.equal(result.reason, 'aborted'); - assert.equal(counts.pageRequests, abortAtPage); - assert.equal(counts.decodedMessages, 0); - } else { - assert.ok(Array.isArray(result)); - assert.equal(result.length, titleHit ? 1 : hitIndex >= 0 ? Math.min(limit, hitCount) : 0); - if (hitIndex >= 0 && !titleHit) { - assert.equal(result[0].target.sequence, hitIndex * stride); - assert.equal(result[0].target.turnId, `turn-${hitIndex}`); - } - } - return { - label, - total, - hitIndex, - hitCount, - limit, - stride, - ...counts, - directions: [...requestedDirections], - outcome: Array.isArray(result) - ? { - results: result.length, - matchedTurnIds: result.map((hit) => hit.target.turnId ?? null), - first: result[0] ?? null, - } - : result, - ...(forwardHasMore === undefined ? {} : { forwardHasMore, pageOnlyTruncated }), - ...(hitIndex < 0 ? {} : { matchingFixtureSequence: hitIndex * stride }), - }; -} - -const results = []; -for (const total of [2, 256, 5000, 20000]) { - results.push(await runScenario({ label: `first-hit-${total}`, total })); -} -results.push(await runScenario({ label: 'first-hit-limit-10', total: 20000, limit: 10 })); -results.push( - await runScenario({ label: 'ten-early-hits-baseline', total: 20000, limit: 10, hitCount: 10 }), -); -results.push( - await runScenario({ - label: 'ten-early-hits-feasibility', - total: 20000, - limit: 10, - hitCount: 10, - firstForwardPageOnly: true, - }), -); -results.push(await runScenario({ label: 'last-hit', total: 20000, hitIndex: 19999 })); -results.push(await runScenario({ label: 'no-hit', total: 20000, hitIndex: -1 })); -results.push(await runScenario({ label: 'title-only', total: 20000, titleHit: true })); -results.push(await runScenario({ label: 'cancel-first-page', total: 20000, abortAtPage: 1 })); -results.push( - await runScenario({ label: 'oldest-page-feasibility', total: 20000, firstForwardPageOnly: true }), -); -results.push(await runScenario({ label: 'page-edge-hit-baseline', total: 20000, hitIndex: 255 })); -results.push( - await runScenario({ - label: 'page-edge-hit-feasibility', - total: 20000, - hitIndex: 255, - firstForwardPageOnly: true, - }), -); -results.push( - await runScenario({ label: 'sparse-sequence-control', total: 3, hitIndex: 1, stride: 8 }), -); -writeFileSync(join(out, 'results.json'), JSON.stringify(results, null, 2) + '\n'); -console.table( - results.map( - ({ - label, - decodedMessages, - messageJsonParses, - pageRequests, - scannedMessageFields, - rawBytes, - }) => ({ - label, - decodedMessages, - messageJsonParses, - pageRequests, - scannedMessageFields, - rawBytes, - }), - ), -); - -const baseline = results.find((row) => row.label === 'first-hit-20000'); -assert.equal(baseline.scannedMessageFields, 1); -const forward = results.find((row) => row.label === 'oldest-page-feasibility'); -assert.equal(forward.pageRequests, 1); -assert.equal(forward.decodedMessages, 256); -assert.equal(forward.forwardHasMore, true); -assert.deepEqual(forward.outcome.first.target.turnId, baseline.outcome.first.target.turnId); -assert.deepEqual( - results.find((row) => row.label === 'ten-early-hits-baseline').outcome.matchedTurnIds, - results.find((row) => row.label === 'ten-early-hits-feasibility').outcome.matchedTurnIds, -); -if (process.argv.includes('--check-budget')) { - // The result budget bounds work to the first page, independent of the - // transcript's total length. No wall-clock threshold is involved. - assert.equal(baseline.decodedMessages, 256); - assert.equal(baseline.pageRequests, 1); - assert.equal( - results.find((row) => row.label === 'page-edge-hit-baseline').outcome.first.truncated, - true, - ); -} diff --git a/docs/performance/history-search-diagnosis-2026-09-20/results.json b/docs/performance/history-search-diagnosis-2026-09-20/results.json deleted file mode 100644 index 90ac9d9209..0000000000 --- a/docs/performance/history-search-diagnosis-2026-09-20/results.json +++ /dev/null @@ -1,586 +0,0 @@ -[ - { - "label": "first-hit-2", - "total": 2, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 0, - "decodedMessages": 2, - "messageJsonParses": 2, - "scannedMessageFields": 1, - "bootstrapMessages": 2, - "rawBytes": 274, - "beforeFirstCandidate": { - "decodedMessages": 2, - "pageRequests": 0 - }, - "directions": [], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-256", - "total": 256, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 1, - "bootstrapMessages": 81, - "rawBytes": 51012, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["older"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-5000", - "total": 5000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 20, - "decodedMessages": 5000, - "messageJsonParses": 5000, - "scannedMessageFields": 1, - "bootstrapMessages": 80, - "rawBytes": 1016557, - "beforeFirstCandidate": { - "decodedMessages": 5000, - "pageRequests": 20 - }, - "directions": ["older"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-20000", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 78, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 1, - "bootstrapMessages": 79, - "rawBytes": 4106558, - "beforeFirstCandidate": { - "decodedMessages": 20000, - "pageRequests": 78 - }, - "directions": ["older"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "first-hit-limit-10", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 10, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 78, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 20000, - "bootstrapMessages": 79, - "rawBytes": 4106558, - "beforeFirstCandidate": { - "decodedMessages": 20000, - "pageRequests": 78 - }, - "directions": ["older"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - } - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "ten-early-hits-baseline", - "total": 20000, - "hitIndex": 0, - "hitCount": 10, - "limit": 10, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 78, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 10, - "bootstrapMessages": 79, - "rawBytes": 4105514, - "beforeFirstCandidate": { - "decodedMessages": 20000, - "pageRequests": 78 - }, - "directions": ["older"], - "outcome": { - "results": 10, - "matchedTurnIds": [ - "turn-0", - "turn-1", - "turn-2", - "turn-3", - "turn-4", - "turn-5", - "turn-6", - "turn-7", - "turn-8", - "turn-9" - ], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0 - } - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "ten-early-hits-feasibility", - "total": 20000, - "hitIndex": 0, - "hitCount": 10, - "limit": 10, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 10, - "bootstrapMessages": 79, - "rawBytes": 66321, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 10, - "matchedTurnIds": [ - "turn-0", - "turn-1", - "turn-2", - "turn-3", - "turn-4", - "turn-5", - "turn-6", - "turn-7", - "turn-8", - "turn-9" - ], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0, - "messageId": "message-0", - "matchKind": "user_message", - "messageTimestamp": 1 - } - } - }, - "forwardHasMore": true, - "pageOnlyTruncated": true, - "matchingFixtureSequence": 0 - }, - { - "label": "last-hit", - "total": 20000, - "hitIndex": 19999, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 78, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 20000, - "bootstrapMessages": 79, - "rawBytes": 4106558, - "beforeFirstCandidate": { - "decodedMessages": 20000, - "pageRequests": 78 - }, - "directions": ["older"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-19999"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-19999", - "sequence": 19999 - } - } - }, - "matchingFixtureSequence": 19999 - }, - { - "label": "no-hit", - "total": 20000, - "hitIndex": -1, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 78, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 20000, - "bootstrapMessages": 79, - "rawBytes": 4106674, - "beforeFirstCandidate": { - "decodedMessages": 20000, - "pageRequests": 78 - }, - "directions": ["older"], - "outcome": { - "results": 0, - "matchedTurnIds": [], - "first": null - } - }, - { - "label": "title-only", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 0, - "closed": 0, - "pageRequests": 0, - "decodedMessages": 0, - "messageJsonParses": 0, - "scannedMessageFields": 0, - "bootstrapMessages": 0, - "rawBytes": 0, - "beforeFirstCandidate": null, - "directions": [], - "outcome": { - "results": 1, - "matchedTurnIds": [null], - "first": { - "source": "thread", - "title": "needleunique", - "summary": "任务标题", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis" - }, - "truncated": true - } - }, - "matchingFixtureSequence": 0 - }, - { - "label": "cancel-first-page", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 0, - "messageJsonParses": 79, - "scannedMessageFields": 0, - "bootstrapMessages": 79, - "rawBytes": 69345, - "beforeFirstCandidate": null, - "directions": ["older"], - "outcome": { - "ok": false, - "reason": "aborted", - "message": "History search was aborted." - }, - "matchingFixtureSequence": 0 - }, - { - "label": "oldest-page-feasibility", - "total": 20000, - "hitIndex": 0, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 1, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-0"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-0", - "sequence": 0, - "messageId": "message-0", - "matchKind": "user_message", - "messageTimestamp": 1 - }, - "truncated": true - } - }, - "forwardHasMore": true, - "pageOnlyTruncated": true, - "matchingFixtureSequence": 0 - }, - { - "label": "page-edge-hit-baseline", - "total": 20000, - "hitIndex": 255, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 78, - "decodedMessages": 20000, - "messageJsonParses": 20000, - "scannedMessageFields": 256, - "bootstrapMessages": 79, - "rawBytes": 4106558, - "beforeFirstCandidate": { - "decodedMessages": 20000, - "pageRequests": 78 - }, - "directions": ["older"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-255"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-255", - "sequence": 255 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 255 - }, - { - "label": "page-edge-hit-feasibility", - "total": 20000, - "hitIndex": 255, - "hitCount": 1, - "limit": 1, - "stride": 1, - "opened": 1, - "closed": 1, - "pageRequests": 1, - "decodedMessages": 256, - "messageJsonParses": 256, - "scannedMessageFields": 256, - "bootstrapMessages": 79, - "rawBytes": 67365, - "beforeFirstCandidate": { - "decodedMessages": 256, - "pageRequests": 1 - }, - "directions": ["newer"], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-255"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-255", - "sequence": 255, - "messageId": "message-255", - "matchKind": "user_message", - "messageTimestamp": 256 - } - } - }, - "forwardHasMore": true, - "pageOnlyTruncated": false, - "matchingFixtureSequence": 255 - }, - { - "label": "sparse-sequence-control", - "total": 3, - "hitIndex": 1, - "hitCount": 1, - "limit": 1, - "stride": 8, - "opened": 1, - "closed": 1, - "pageRequests": 0, - "decodedMessages": 3, - "messageJsonParses": 3, - "scannedMessageFields": 2, - "bootstrapMessages": 3, - "rawBytes": 469, - "beforeFirstCandidate": { - "decodedMessages": 3, - "pageRequests": 0 - }, - "directions": [], - "outcome": { - "results": 1, - "matchedTurnIds": ["turn-1"], - "first": { - "source": "thread", - "title": "History review", - "summary": "用户消息", - "snippet": "needleunique", - "target": { - "kind": "thread", - "sessionId": "history-search-diagnosis", - "turnId": "turn-1", - "sequence": 1 - }, - "truncated": true - } - }, - "matchingFixtureSequence": 8 - } -]