From c6360434f391635cb118cfa03706942272a34c31 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 10 Sep 2026 20:33:49 +0700 Subject: [PATCH 1/2] Add general search-entries host tool over the entry endpoint Co-Authored-By: Claude Fable 5 --- packages/base/command.gts | 12 +- .../base/commands/search-entry-result.gts | 67 ++++ packages/host/app/tools/index.ts | 3 + packages/host/app/tools/search-cards.ts | 14 +- packages/host/app/tools/search-entries.ts | 194 +++++++++ .../host/app/utils/search/query-builder.ts | 4 +- .../tools/search-entries-tool-test.gts | 368 ++++++++++++++++++ 7 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 packages/base/commands/search-entry-result.gts create mode 100644 packages/host/app/tools/search-entries.ts create mode 100644 packages/host/tests/integration/tools/search-entries-tool-test.gts diff --git a/packages/base/command.gts b/packages/base/command.gts index 46be5917289..919f1bdd3b5 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -25,6 +25,11 @@ import { SearchCardsResult, SearchCardSummaryField, } from './commands/search-card-result'; +import { + SearchEntriesInput, + SearchEntriesResult, + SearchEntrySummaryField, +} from './commands/search-entry-result'; import { eq, gt } from '@cardstack/boxel-ui/helpers'; export type ToolCallStatus = 'applied' | 'ready' | 'applying'; @@ -276,7 +281,9 @@ export class ScreenshotCardOutput extends CardDef {
{{#if capture.url}} {{capture.name}} -
{{capture.url}}
+
{{capture.url}}
{{/if}}
{{/each}} @@ -659,6 +666,9 @@ export { SearchCardsByTypeAndTitleInput, SearchCardsResult, SearchCardSummaryField, + SearchEntriesInput, + SearchEntriesResult, + SearchEntrySummaryField, }; export class RealmInfoField extends FieldDef { diff --git a/packages/base/commands/search-entry-result.gts b/packages/base/commands/search-entry-result.gts new file mode 100644 index 00000000000..e0d69069b9b --- /dev/null +++ b/packages/base/commands/search-entry-result.gts @@ -0,0 +1,67 @@ +import { IconSearchThick } from '@cardstack/boxel-ui/icons'; +import { + CardDef, + Component, + FieldDef, + StringField, + contains, + containsMany, + field, +} from '../card-api'; +import CodeRefField from '../code-ref'; +import NumberField from '../number'; +import { QueryField } from './search-card-result'; + +export class SearchEntriesInput extends CardDef { + static displayName = 'Search Entries'; + static icon = IconSearchThick; + @field query = contains(QueryField); + // Realm URLs to search; empty means every realm the user can read. + @field realms = containsMany(StringField); + // 'cards' | 'files' | 'all'; the tool validates and defaults to 'all'. + @field scope = contains(StringField); + // Maximum rows returned; the tool defaults to 5 and clamps to 10. + @field limit = contains(NumberField); +} + +export class SearchEntrySummaryField extends FieldDef { + static displayName = 'Search Entry Summary'; + // The entry's URL — a card id or a file URL. + @field url = contains(StringField); + // 'card' | 'file', from the entry's item resource type. + @field kind = contains(StringField); + // Populated for Spec rows. + @field ref = contains(CodeRefField); + @field specType = contains(StringField); + @field cardTitle = contains(StringField); + @field cardDescription = contains(StringField); + // Present only when the search sorted by full-text relevance. + @field matchRelevance = contains(NumberField); + // Full, untruncated readMe when the row carries one (e.g. a Spec). + @field readMe = contains(StringField); +} + +export class SearchEntriesResult extends CardDef { + static displayName = 'Search Entries Result'; + static icon = IconSearchThick; + @field results = containsMany(SearchEntrySummaryField); + // Total matches across the searched realms; `results` is one page of them. + @field total = contains(NumberField); + @field cardDescription = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} diff --git a/packages/host/app/tools/index.ts b/packages/host/app/tools/index.ts index f0e94ad8c2a..fe8f35a84d2 100644 --- a/packages/host/app/tools/index.ts +++ b/packages/host/app/tools/index.ts @@ -72,6 +72,7 @@ import * as SaveCardToolModule from './save-card'; import * as ScreenshotCardToolModule from './screenshot-card'; import * as SearchAndChooseToolModule from './search-and-choose'; import * as SearchCardsToolModule from './search-cards'; +import * as SearchEntriesToolModule from './search-entries'; import * as SearchGoogleImagesToolModule from './search-google-images'; import * as SendAiAssistantMessageModule from './send-ai-assistant-message'; import * as SendRequestViaProxyToolModule from './send-request-via-proxy'; @@ -277,6 +278,7 @@ export function shimHostTools(virtualNetwork: VirtualNetwork) { shimHostToolModule(virtualNetwork, 'save-card', SaveCardToolModule); shimHostToolModule(virtualNetwork, 'serialize-card', SerializeCardToolModule); shimHostToolModule(virtualNetwork, 'search-cards', SearchCardsToolModule); + shimHostToolModule(virtualNetwork, 'search-entries', SearchEntriesToolModule); shimHostToolModule( virtualNetwork, 'search-and-choose', @@ -530,6 +532,7 @@ export const HostToolClasses: (typeof HostBaseTool)[] = [ SearchAndChooseToolModule.default, SearchCardsToolModule.SearchCardsByQueryTool, SearchCardsToolModule.SearchCardsByTypeAndTitleTool, + SearchEntriesToolModule.default, SearchGoogleImagesToolModule.default, SendAiAssistantMessageModule.default, SendBotTriggerEventToolModule.default, diff --git a/packages/host/app/tools/search-cards.ts b/packages/host/app/tools/search-cards.ts index 5e3b78130f2..8539828cf48 100644 --- a/packages/host/app/tools/search-cards.ts +++ b/packages/host/app/tools/search-cards.ts @@ -14,7 +14,11 @@ export class SearchCardsByTypeAndTitleTool extends HostBaseTool< typeof BaseToolModule.SearchCardsByTypeAndTitleInput, typeof BaseToolModule.SearchCardsResult > { - description = 'Search for card instances by type and/or title'; + description = + 'Search for live card instances by type and/or title when you need the ' + + 'instances themselves — to attach, open, copy, or patch. For discovery ' + + '(finding what exists — card types, specs, listings, files), use the ' + + 'search-entries tool instead.'; static actionVerb = 'Search'; @@ -58,9 +62,11 @@ export class SearchCardsByQueryTool extends HostBaseTool< @service declare private realmServer: RealmServerService; description = - 'Propose a query to search for a card instance filtered by type. \ - If a card was shared with you, always prioritize search based upon the card that was last shared. \ - If you do not have information on card module and name, do the search using the `_cardType` attribute.'; + 'Search for live card instances by query when you need the instances ' + + 'themselves — to attach, open, copy, or patch. If a card was shared with ' + + 'you, always prioritize search based upon the card that was last shared. ' + + 'For discovery (finding what exists — card types, specs, listings, ' + + 'files, or anything reusable), use the search-entries tool instead.'; async getInputType() { let commandModule = await this.loadToolModule(); diff --git a/packages/host/app/tools/search-entries.ts b/packages/host/app/tools/search-entries.ts new file mode 100644 index 00000000000..adc03fc4fbf --- /dev/null +++ b/packages/host/app/tools/search-entries.ts @@ -0,0 +1,194 @@ +import { service } from '@ember/service'; + +import { + MATCH_RELEVANCE_SORT_KEY, + assertQuery, + collectPositiveMatchTerms, + excludeCardInstanceFileRows, + isFileMetaResource, + resourceIdentity, + searchEntryWireQueryFromQuery, + type CardResource, + type EntryCollectionDocument, + type FileMetaResource, + type Query, + type Saved, + type SearchEntryScope, +} from '@cardstack/runtime-common'; + +import HostBaseTool from '../lib/host-base-tool'; +import { hasNarrowingPositiveTypeRef } from '../utils/search/query-builder'; + +import type StoreService from '../services/store'; +import type * as BaseToolModule from '@cardstack/base/command'; + +const DEFAULT_LIMIT = 5; +const MAX_LIMIT = 10; + +const SCOPES: SearchEntryScope[] = ['cards', 'files', 'all']; + +// The fixed projection each row carries. Everything a discovery pass needs to +// judge a candidate rides the row itself (including the full readMe), so no +// per-hit follow-up read is required; the fieldset stays internal so a caller +// can never request full serializations that blow up the result size. +const PROJECTION_FIELDS = [ + 'item.cardTitle', + 'item.cardDescription', + 'item.specType', + 'item.readMe', + 'item.ref', +]; + +interface EntrySummary { + url: string; + kind: 'card' | 'file'; + ref?: unknown; + specType?: string; + cardTitle?: string; + cardDescription?: string; + readMe?: string; + matchRelevance?: number; +} + +function resolveScope(scope: string | undefined): SearchEntryScope { + if (scope == null || scope === '') { + return 'all'; + } + if (!SCOPES.includes(scope as SearchEntryScope)) { + throw new Error( + `Invalid scope "${scope}": must be one of 'cards', 'files', 'all'`, + ); + } + return scope as SearchEntryScope; +} + +// Prepares a caller's card-rooted query for the entry endpoint: under the +// mixed 'all' scope a card matches both its instance row and its dual-indexed +// `.json` file row, so the filter gains the card-instance-file exclusion — +// unless it already carries a kind-narrowing positive type ref, which matches +// only one of the two. A filter with a positive full-text `matches` term and +// no explicit sort gains the relevance sort (the server rejects that sort +// without such a term, so it is never added unconditionally). +export function composeSearchEntriesQuery( + query: Query, + scope: SearchEntryScope, +): Query { + let filter = query.filter; + if (scope === 'all' && !hasNarrowingPositiveTypeRef(filter)) { + filter = filter + ? { every: [filter, excludeCardInstanceFileRows()] } + : excludeCardInstanceFileRows(); + } + let sort = query.sort; + if (!sort && collectPositiveMatchTerms(query.filter).length > 0) { + sort = [{ by: MATCH_RELEVANCE_SORT_KEY, direction: 'desc' }]; + } + return { + ...query, + ...(filter ? { filter } : {}), + ...(sort ? { sort } : {}), + }; +} + +function summarizeEntries(doc: EntryCollectionDocument): EntrySummary[] { + let itemsByIdentity = new Map< + string, + CardResource | FileMetaResource + >(); + for (let resource of doc.included ?? []) { + if (resource.type === 'card' || resource.type === 'file-meta') { + itemsByIdentity.set( + resourceIdentity(resource.type, resource.id), + resource, + ); + } + } + let summaries: EntrySummary[] = []; + for (let entry of doc.data) { + let itemRef = entry.relationships?.item?.data; + if (!entry.id || !itemRef) { + continue; + } + let item = itemsByIdentity.get(resourceIdentity(itemRef.type, itemRef.id)); + if (!item) { + continue; + } + let attributes = (item.attributes ?? {}) as Record; + summaries.push({ + url: entry.id, + kind: isFileMetaResource(item) ? 'file' : 'card', + ref: attributes.ref, + specType: attributes.specType as string | undefined, + cardTitle: attributes.cardTitle as string | undefined, + cardDescription: attributes.cardDescription as string | undefined, + readMe: attributes.readMe as string | undefined, + matchRelevance: entry.meta?._matchRelevance, + }); + } + return summaries; +} + +export default class SearchEntriesTool extends HostBaseTool< + typeof BaseToolModule.SearchEntriesInput, + typeof BaseToolModule.SearchEntriesResult +> { + @service declare private store: StoreService; + + static actionVerb = 'Search'; + + description = + 'Search across realms for existing cards, specs, listings, themes, and files — ' + + 'the primary tool for discovery: always check what already exists before creating ' + + 'anything new. Takes a card query (`filter` supporting `type`/`on`/`eq`/`contains`/' + + '`range`/`any`/`every`/`not` and full-text `matches`, plus optional `sort`), optional ' + + '`realms` (realm URLs; defaults to every realm you can read), optional `scope` ' + + "('cards' | 'files' | 'all', default 'all'), and optional `limit` (default " + + `${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Returns lightweight entry summaries — url, ` + + 'ref, specType, title, description, full readMe, and full-text match relevance — ' + + 'not live card instances. When you need instances to attach, open, or patch, use ' + + 'the card-instance search tools instead.'; + + requireInputFields = ['query']; + + async getInputType() { + let commandModule = await this.loadToolModule(); + return commandModule.SearchEntriesInput; + } + + protected async run( + input: BaseToolModule.SearchEntriesInput, + ): Promise { + assertQuery(input.query); + let scope = resolveScope(input.scope); + let limit = Math.min( + Math.max(Math.floor(input.limit ?? DEFAULT_LIMIT), 1), + MAX_LIMIT, + ); + + let wireQuery = searchEntryWireQueryFromQuery( + composeSearchEntriesQuery(input.query, scope), + { fields: PROJECTION_FIELDS, scope }, + ); + wireQuery.page = { ...wireQuery.page, size: limit }; + + let realms = input.realms?.length ? [...input.realms] : undefined; + let doc = await this.store.searchEntries(wireQuery, realms); + + let rows = summarizeEntries(doc); + if (rows.some((row) => row.matchRelevance !== undefined)) { + // The federated merge concatenates per-realm results without re-ranking + // across realms; relevance rides each entry so the merged page can be. + rows = [...rows].sort( + (a, b) => (b.matchRelevance ?? -1) - (a.matchRelevance ?? -1), + ); + } + + let commandModule = await this.loadToolModule(); + let { SearchEntriesResult, SearchEntrySummaryField } = commandModule; + return new SearchEntriesResult({ + results: rows.map((row) => new SearchEntrySummaryField(row)), + total: doc.meta.page.total, + cardDescription: `Query: ${JSON.stringify(input.query.filter ?? {})}`, + }); + } +} diff --git a/packages/host/app/utils/search/query-builder.ts b/packages/host/app/utils/search/query-builder.ts index 3fbb9bbbb5c..4c8c4bfd3f9 100644 --- a/packages/host/app/utils/search/query-builder.ts +++ b/packages/host/app/utils/search/query-builder.ts @@ -166,7 +166,9 @@ function isRootTypeRef(ref: CodeRef): boolean { ); } -function hasNarrowingPositiveTypeRef(filter: Filter | undefined): boolean { +export function hasNarrowingPositiveTypeRef( + filter: Filter | undefined, +): boolean { if (!filter) { return false; } diff --git a/packages/host/tests/integration/tools/search-entries-tool-test.gts b/packages/host/tests/integration/tools/search-entries-tool-test.gts new file mode 100644 index 00000000000..890bd9fd487 --- /dev/null +++ b/packages/host/tests/integration/tools/search-entries-tool-test.gts @@ -0,0 +1,368 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { type Query, rri } from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; + +import SearchEntriesTool, { + composeSearchEntriesQuery, +} from '@cardstack/host/tools/search-entries'; + +import { + testRealmURL, + setupCardLogs, + setupIntegrationTestRealm, + setupLocalIndexing, + setupOnSave, + setupRealmCacheTeardown, + withCachedRealmSetup, + realmConfigCardJSON, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +const longReadMe = `# Author card\n\n${'The Author card models a writer with biographical fields. '.repeat(20)}`; + +module('Integration | tools | search-entries', function (hooks) { + setupRenderingTest(hooks); + + const realmName = 'Search Entries Workspace'; + let loader: Loader; + + hooks.beforeEach(function () { + loader = getService('loader-service').loader; + }); + + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupRealmCacheTeardown(hooks); + setupCardLogs( + hooks, + async () => await loader.import('@cardstack/base/card-api'), + ); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + function runSearch(input: { + query: Query; + realms?: string[]; + scope?: string; + limit?: number; + }) { + let toolService = getService('tool-service'); + let tool = new SearchEntriesTool(toolService.toolContext); + return tool.execute(input); + } + + hooks.beforeEach(async function () { + loader = getService('loader-service').loader; + let cardApi: typeof import('@cardstack/base/card-api'); + let string: typeof import('@cardstack/base/string'); + let spec: typeof import('@cardstack/base/spec'); + + cardApi = await loader.import('@cardstack/base/card-api'); + string = await loader.import('@cardstack/base/string'); + spec = await loader.import('@cardstack/base/spec'); + + let { field, contains, CardDef } = cardApi; + let { default: StringField } = string; + let { Spec } = spec; + + class Author extends CardDef { + static displayName = 'Author'; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field bio = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Author) { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }, + }); + } + + let authorInstances: Record = {}; + for (let i = 1; i <= 12; i++) { + authorInstances[`Author/author-${i}.json`] = new Author({ + firstName: `Author${i}`, + lastName: 'Example', + bio: `Prolific example writer number ${i}.`, + }); + } + + await withCachedRealmSetup(async () => { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'author.gts': { Author }, + ...authorInstances, + 'Author/mark.json': new Author({ + firstName: 'Mark', + lastName: 'Jackson', + bio: 'Novelist specializing in xylophone-themed mystery fiction.', + }), + 'Spec/author.json': new Spec({ + cardTitle: 'Author', + cardDescription: 'Spec for the Author card definition', + specType: 'card', + readMe: longReadMe, + ref: { + module: `${testRealmURL}author`, + name: 'Author', + }, + }), + 'notes.md': '# Workspace notes\n\nA plain markdown file fixture.', + 'realm.json': realmConfigCardJSON({ + name: realmName, + iconURL: 'https://boxel-images.boxel.ai/icons/Letter-o.png', + }), + }, + }); + }); + }); + + test('basic typed query returns entry summaries, not instances', async function (assert) { + let result = await runSearch({ + query: { + filter: { + eq: { firstName: 'Mark' }, + on: { module: rri(`${testRealmURL}author`), name: 'Author' }, + }, + }, + }); + assert.strictEqual(result.results.length, 1); + let row = result.results[0]; + assert.strictEqual(row.url, `${testRealmURL}Author/mark`); + assert.strictEqual(row.kind, 'card'); + assert.strictEqual(row.cardTitle, 'Mark Jackson'); + assert.strictEqual(result.total, 1); + }); + + test('spec rows carry ref, specType, and the full readMe', async function (assert) { + let result = await runSearch({ + query: { + filter: { + on: { module: rri('https://cardstack.com/base/spec'), name: 'Spec' }, + eq: { specType: 'card', cardTitle: 'Author' }, + }, + }, + }); + assert.strictEqual(result.results.length, 1); + let row = result.results[0]; + assert.strictEqual(row.url, `${testRealmURL}Spec/author`); + assert.strictEqual(row.specType, 'card'); + assert.deepEqual( + { module: row.ref?.module, name: row.ref?.name }, + { module: `${testRealmURL}author`, name: 'Author' }, + ); + assert.strictEqual( + row.readMe, + longReadMe, + 'readMe rides the result in full, untruncated', + ); + assert.strictEqual( + row.cardDescription, + 'Spec for the Author card definition', + ); + }); + + test('scope narrows to files or cards', async function (assert) { + // `_title` is the kind-neutral title key (a card's cardTitle, a file's + // name), so one spelling filters both scopes. + let filesResult = await runSearch({ + query: { filter: { contains: { _title: 'notes' } } }, + scope: 'files', + }); + assert.ok( + filesResult.results.some( + (r: { url: string }) => r.url === `${testRealmURL}notes.md`, + ), + 'files scope surfaces the plain file', + ); + assert.ok( + filesResult.results.every((r: { kind: string }) => r.kind === 'file'), + 'files scope returns only file rows', + ); + + let cardsResult = await runSearch({ + query: { filter: { contains: { _title: 'notes' } } }, + scope: 'cards', + }); + assert.ok( + cardsResult.results.every((r: { kind: string }) => r.kind === 'card'), + 'cards scope returns only card rows', + ); + }); + + test('invalid scope is rejected with the legal values', async function (assert) { + await assert.rejects( + runSearch({ + query: { filter: { matches: 'xylophone' } }, + scope: 'modules', + }), + /cards.*files.*all/, + ); + }); + + test('default scope deduplicates a card against its own file row', async function (assert) { + let result = await runSearch({ + query: { filter: { matches: 'xylophone' } }, + }); + let markRows = result.results.filter((r: { url: string }) => + r.url.startsWith(`${testRealmURL}Author/mark`), + ); + assert.strictEqual( + markRows.length, + 1, + 'the matching card appears once, not once per index row', + ); + }); + + test('matches query yields relevance-sorted rows; non-matches yields none', async function (assert) { + let withMatches = await runSearch({ + query: { filter: { matches: 'xylophone' } }, + }); + assert.ok(withMatches.results.length >= 1); + let relevances = withMatches.results.map( + (r: { matchRelevance?: number }) => r.matchRelevance, + ); + assert.ok( + relevances.every((r: number | undefined) => typeof r === 'number'), + 'every row carries a numeric matchRelevance', + ); + let sorted = [...relevances].sort((a, b) => b! - a!); + assert.deepEqual(relevances, sorted, 'rows are sorted by relevance desc'); + + let withoutMatches = await runSearch({ + query: { + filter: { + eq: { firstName: 'Mark' }, + on: { module: rri(`${testRealmURL}author`), name: 'Author' }, + }, + }, + }); + assert.ok( + withoutMatches.results.every( + (r: { matchRelevance?: number }) => r.matchRelevance == null, + ), + 'no relevance without a matches term (and no 400 from the sort)', + ); + }); + + test('limit defaults to 5, is honored, and clamps at 10', async function (assert) { + let query: Query = { + filter: { + on: { module: rri(`${testRealmURL}author`), name: 'Author' }, + contains: { lastName: 'Example' }, + }, + }; + let defaulted = await runSearch({ query }); + assert.strictEqual(defaulted.results.length, 5, 'default limit is 5'); + assert.strictEqual(defaulted.total, 12, 'total reports the real count'); + + let three = await runSearch({ query, limit: 3 }); + assert.strictEqual(three.results.length, 3); + + let clamped = await runSearch({ query, limit: 50 }); + assert.strictEqual(clamped.results.length, 10, 'limit clamps to 10'); + }); + + test('realms input targets the given realm', async function (assert) { + let result = await runSearch({ + query: { + filter: { + eq: { firstName: 'Mark' }, + on: { module: rri(`${testRealmURL}author`), name: 'Author' }, + }, + }, + realms: [testRealmURL], + }); + assert.strictEqual(result.results.length, 1); + assert.strictEqual(result.results[0].url, `${testRealmURL}Author/mark`); + }); + + test('the tool module has a default export (skill declarations use name: default)', function (assert) { + // The top-of-file `import SearchEntriesTool from ...` is a default import; + // it resolving to the class is the declaration-shape guard. + assert.ok(SearchEntriesTool, 'default export exists'); + }); + + module('query composition', function () { + test('mixed-scope dedup wraps a non-narrowing filter', function (assert) { + let composed = composeSearchEntriesQuery( + { filter: { matches: 'xylophone' } }, + 'all', + ); + assert.ok( + 'every' in (composed.filter ?? {}), + 'filter is wrapped with the card-instance-file exclusion', + ); + }); + + test('a narrowing positive type anchor skips the dedup wrap', function (assert) { + let filter: Query['filter'] = { + on: { module: rri(`${testRealmURL}author`), name: 'Author' }, + eq: { firstName: 'Mark' }, + }; + let composed = composeSearchEntriesQuery({ filter }, 'all'); + assert.deepEqual(composed.filter, filter, 'filter passes through as-is'); + }); + + test('explicit cards scope skips the dedup wrap', function (assert) { + let composed = composeSearchEntriesQuery( + { filter: { matches: 'xylophone' } }, + 'cards', + ); + assert.deepEqual(composed.filter, { matches: 'xylophone' }); + }); + + test('a matches filter with no sort gains the relevance sort', function (assert) { + let composed = composeSearchEntriesQuery( + { filter: { matches: 'xylophone' } }, + 'cards', + ); + assert.deepEqual(composed.sort, [ + { by: '_matchRelevance', direction: 'desc' }, + ]); + }); + + test('an explicit sort and a matches-free filter stay untouched', function (assert) { + let sorted = composeSearchEntriesQuery( + { + filter: { matches: 'xylophone' }, + sort: [{ by: 'cardTitle', direction: 'asc' }], + }, + 'cards', + ); + assert.deepEqual(sorted.sort, [{ by: 'cardTitle', direction: 'asc' }]); + + let noMatches = composeSearchEntriesQuery( + { filter: { eq: { firstName: 'Mark' } } }, + 'cards', + ); + assert.strictEqual( + noMatches.sort, + undefined, + 'no relevance sort without a positive matches term', + ); + }); + + test('a negated matches term does not trigger the relevance sort', function (assert) { + let composed = composeSearchEntriesQuery( + { + filter: { + every: [ + { eq: { firstName: 'Mark' } }, + { not: { matches: 'xylophone' } }, + ], + }, + }, + 'cards', + ); + assert.strictEqual(composed.sort, undefined); + }); + }); +}); From 77b0ebd2d8615cb9ff041f8e7950675469336cc3 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 11 Sep 2026 15:40:37 +0700 Subject: [PATCH 2/2] Surface partial results, respect caller sorts, and name file rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings addressed: - A realm that fails during the federated fan-out marks the merged document incomplete rather than throwing; the result card now carries that as an `incomplete` field and the tool description tells the model the total undercounts. - The client-side relevance re-sort now keys on the tool having added the sort itself (`composeSearchEntriesQuery` reports the addition), so a caller's explicit ordering — which may legally include `_matchRelevance` — is never overridden. - The fixed projection gains `item.name`, the file-meta display handle, so a file row's summary carries more than its bare URL. - `hasNarrowingPositiveTypeRef` is now structure-aware: an `any` narrows only when every branch does, so a disjunction with an unanchored branch keeps the mixed-scope dedup instead of surfacing a card twice. Co-Authored-By: Claude Fable 5 --- .../base/commands/search-entry-result.gts | 14 ++- packages/host/app/tools/search-entries.ts | 48 +++++++--- .../host/app/utils/search/query-builder.ts | 38 ++++++-- .../tools/search-entries-tool-test.gts | 88 +++++++++++++++++-- 4 files changed, 162 insertions(+), 26 deletions(-) diff --git a/packages/base/commands/search-entry-result.gts b/packages/base/commands/search-entry-result.gts index e0d69069b9b..8d7a53206bc 100644 --- a/packages/base/commands/search-entry-result.gts +++ b/packages/base/commands/search-entry-result.gts @@ -8,6 +8,7 @@ import { containsMany, field, } from '../card-api'; +import BooleanField from '../boolean'; import CodeRefField from '../code-ref'; import NumberField from '../number'; import { QueryField } from './search-card-result'; @@ -35,6 +36,9 @@ export class SearchEntrySummaryField extends FieldDef { @field specType = contains(StringField); @field cardTitle = contains(StringField); @field cardDescription = contains(StringField); + // The file name — the display handle for file rows, which carry no + // cardTitle. + @field name = contains(StringField); // Present only when the search sorted by full-text relevance. @field matchRelevance = contains(NumberField); // Full, untruncated readMe when the row carries one (e.g. a Spec). @@ -47,16 +51,22 @@ export class SearchEntriesResult extends CardDef { @field results = containsMany(SearchEntrySummaryField); // Total matches across the searched realms; `results` is one page of them. @field total = contains(NumberField); + // True when a searched realm failed to answer: `results`/`total` then cover + // only the realms that responded. + @field incomplete = contains(BooleanField); @field cardDescription = contains(StringField); static embedded = class Embedded extends Component {