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