diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 30303b213..d5ae7baa6 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to GitNexus will be documented in this file. ### Changed - **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293) +- **MCP `query` keeps one `process_symbols` row per `(id, process_id)`** — a symbol in more than one execution flow stays on each process card, and `symbol_count` is the number of those emitted rows after `max_symbols`. A `repo` of `@` returns `{ group, query, results, per_repo }` and does not include `process_symbols`; `results[].symbol_count` is that member's post-slice count, and a `service` prefix counts only attaches under the prefix. Query `@/` for that member's attach rows (#3351) ## [1.6.12] - 2026-09-12 diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 2ab72bffc..cdec2652b 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -229,10 +229,17 @@ function filterQueryByServicePrefix( servicePrefix, ), ); - const allowed = new Set( - symbols.map((s) => String((s as { process_id?: string }).process_id ?? '')).filter(Boolean), - ); - const processes = (queryResult.processes || []).filter((p) => allowed.has(String(p.id))); + const countByProcess = new Map(); + for (const symbol of symbols) { + const pid = String((symbol as { process_id?: string }).process_id ?? ''); + if (!pid) continue; + countByProcess.set(pid, (countByProcess.get(pid) ?? 0) + 1); + } + const processes = (queryResult.processes || []).filter((p) => countByProcess.has(String(p.id))); + for (const process of processes) { + const id = String(process.id ?? ''); + if (id) process.symbol_count = countByProcess.get(id) ?? 0; + } return { processes, process_symbols: symbols }; } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 34616a734..31720337f 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -25,6 +25,7 @@ import { querySpringAopMetadata } from './aop-metadata.js'; import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; +import { shapeQueryProcessAttaches } from './query-process-attaches.js'; import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js'; import { chunk, mapConcurrent } from '../../lib/utils.js'; import { pathSuffixOf } from './path-predicate.js'; @@ -3547,38 +3548,9 @@ export class LocalBackend { // Step 4: Build response timer.start('formatting'); - const processes = rankedProcesses.map((p) => ({ - id: p.id, - summary: p.heuristicLabel || p.label, - priority: Math.round(p.priority * 1000) / 1000, - symbol_count: p.symbols.length, - process_type: p.processType, - step_count: p.stepCount, - ...(p.routes && p.routes.length > 0 - ? { - route: p.routes[0].url, - method: p.routes[0].method || undefined, - routes: p.routes, - } - : {}), - ...(chainByProcessId.has(p.id) ? { chain: chainByProcessId.get(p.id) } : {}), - })); - - const processSymbols = rankedProcesses.flatMap((p) => - p.symbols.slice(0, maxSymbolsPerProcess).map((s) => ({ - ...s, - // mark the entry-point symbol so an agent reading the - // process can tell procedure vs. workflow vs. helper at a glance. - ...(p.entryPointId && s.id === p.entryPointId ? { is_entry_point: true } : {}), - })), - ); - - // Deduplicate process_symbols by id - const seen = new Set(); - const dedupedSymbols = processSymbols.filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; + const { processes, process_symbols } = shapeQueryProcessAttaches(rankedProcesses, { + maxSymbolsPerProcess, + chainByProcessId, }); timer.stop(); // formatting @@ -3762,7 +3734,7 @@ export class LocalBackend { return { processes, - process_symbols: dedupedSymbols, + process_symbols, definitions: definitions.slice(0, 20), // cap standalone definitions timing, ...(contentAvailability ? { contentAvailability } : {}), diff --git a/gitnexus/src/mcp/local/query-process-attaches.ts b/gitnexus/src/mcp/local/query-process-attaches.ts new file mode 100644 index 000000000..963978118 --- /dev/null +++ b/gitnexus/src/mcp/local/query-process-attaches.ts @@ -0,0 +1,93 @@ +/** + * Shape ranked query processes into the MCP `process_symbols` attach list + * and honest per-process `symbol_count` (#3351). + * + * Aggregation already pushes a hub onto every owning process. This helper + * is the last shaping step: slice each process to `max_symbols`, keep one + * row per `(id, process_id)`, then set `symbol_count` from those rows. + */ + +export type QueryProcessAttach = { + id: string; + process_id: string; + is_entry_point?: boolean; +} & Record; + +export type RankedQueryProcess = { + id: string; + label?: string; + heuristicLabel?: string; + processType?: string; + stepCount?: number; + entryPointId?: string; + priority: number; + symbols: S[]; + routes?: Array<{ url: string; method?: string }>; +}; + +export type QueryProcessCard = { + id: string; + summary: string; + priority: number; + symbol_count: number; + process_type?: string; + step_count?: number; + route?: string; + method?: string; + routes?: Array<{ url: string; method?: string }>; + chain?: unknown; +}; + +/** + * Pair key for attach rows. Both `id` and `process_id` routinely contain + * colons (`func:validate`, `proc:login-flow`), so a `:` join would collide. + */ +function attachPairKey(id: string, processId: string): string { + return `${id}\0${processId}`; +} + +export function shapeQueryProcessAttaches( + rankedProcesses: Array>, + options: { + maxSymbolsPerProcess: number; + chainByProcessId?: ReadonlyMap; + }, +): { processes: QueryProcessCard[]; process_symbols: S[] } { + const { maxSymbolsPerProcess, chainByProcessId } = options; + const seen = new Set(); + const process_symbols: S[] = []; + const countByProcess = new Map(); + + for (const p of rankedProcesses) { + for (const s of p.symbols.slice(0, maxSymbolsPerProcess)) { + const key = attachPairKey(s.id, s.process_id); + if (seen.has(key)) continue; + seen.add(key); + const row = + p.entryPointId && s.id === p.entryPointId ? ({ ...s, is_entry_point: true } as S) : s; + process_symbols.push(row); + countByProcess.set(s.process_id, (countByProcess.get(s.process_id) ?? 0) + 1); + } + } + + const processes = rankedProcesses.map((p) => { + const card: QueryProcessCard = { + id: p.id, + summary: p.heuristicLabel || p.label || '', + priority: Math.round(p.priority * 1000) / 1000, + symbol_count: countByProcess.get(p.id) ?? 0, + process_type: p.processType, + step_count: p.stepCount, + }; + if (p.routes && p.routes.length > 0) { + card.route = p.routes[0].url; + card.method = p.routes[0].method || undefined; + card.routes = p.routes; + } + const chain = chainByProcessId?.get(p.id); + if (chain !== undefined) card.chain = chain; + return card; + }); + + return { processes, process_symbols }; +} diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index d4de65bea..4daaf27d4 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -141,14 +141,14 @@ specify the "repo" parameter explicitly.`, { name: 'query', description: `Query the code knowledge graph for execution flows related to a concept. -Returns processes (call chains) ranked by relevance, each with its symbols and file locations. +Returns ranked processes plus a flat process_symbols list. Join processes[].id to process_symbols[].process_id. WHEN TO USE: Understanding how code works together. Use this when you need execution flows and relationships, not just file matches. Complements grep/IDE search. AFTER THIS: Use context() on a specific symbol for 360-degree view (callers, callees, categorized refs). Returns results grouped by process (execution flow): - processes: ranked execution flows with relevance priority. When a process has an HTTP endpoint, each item includes route and method string aliases plus routes: [{ url, method? }] (same shape as context). When chain_depth > 0, each item also includes chain — layered upstream callers + downstream callees from the process entry symbol (same BFS as context({chain_depth})). -- process_symbols: search-hit symbols in those flows with file locations and module (functional area). When the process entry is among those hits, it is marked is_entry_point: true. +- process_symbols: search-hit symbols in those flows with file locations and module (functional area). On the single-repo envelope { processes, process_symbols, definitions }: One row per (id, process_id) — the same symbol id may appear under more than one process. Join a process to its rows by process_id; symbol_count is the number of those rows. When the process entry is among those hits, it is marked is_entry_point: true. A repo of "@" returns { group, query, results, per_repo } and does not include process_symbols. results[].symbol_count is the member's post-slice attach count; when service is set, it counts only attaches under that prefix. To get process_symbols for one member, query again with repo "@/" (member path from group.yaml, or results[]._repo). - definitions: standalone types/interfaces not in any process. Keyword hits on Route URLs (route_fts) are bridged to their handler via HANDLES_ROUTE (handlerSymbolId, routes) when the edge exists; use route_map({route}) for the full HTTP surface. Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion. diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index f3000cb48..d76b3b716 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -316,12 +316,23 @@ withTestLbugDB( expect(processIds).toContain('proc:login-flow'); expect(processIds).toContain('proc:beta-flow'); - // process_symbols dedups by id, so validate appears once carrying the - // pid+step of its top-ranked process — they must come from the SAME - // shifted row: login-flow⇒step 2, beta-flow⇒step 3. - const v = (res.process_symbols ?? []).find((s: any) => s.id === 'func:validate'); - expect(v).toBeDefined(); - expect(v.step_index).toBe(v.process_id === 'proc:beta-flow' ? 3 : 2); + // process_symbols keeps one row per (id, process_id): validate is a + // step in both flows and must appear under each, with that row's + // shifted pid↔step pairing (login-flow⇒step 2, beta-flow⇒step 3). + const validates = (res.process_symbols ?? []).filter((s: any) => s.id === 'func:validate'); + expect(validates).toHaveLength(2); + const byProcess = Object.fromEntries(validates.map((s: any) => [s.process_id, s])); + expect(byProcess['proc:login-flow']?.step_index).toBe(2); + expect(byProcess['proc:beta-flow']?.step_index).toBe(3); + expect(byProcess['proc:login-flow']?.is_entry_point).toBeUndefined(); + expect(byProcess['proc:beta-flow']?.is_entry_point).toBeUndefined(); + for (const procId of ['proc:login-flow', 'proc:beta-flow'] as const) { + const card = (res.processes ?? []).find((p: any) => p.id === procId); + const attachCount = (res.process_symbols ?? []).filter( + (s: any) => s.process_id === procId, + ).length; + expect(card?.symbol_count).toBe(attachCount); + } // Ranking: 'login' surfaces proc:login-flow as the top process. const loginRes = await backend.callTool('query', { query: 'login' }); diff --git a/gitnexus/test/unit/eval-formatters.test.ts b/gitnexus/test/unit/eval-formatters.test.ts index 57cff0b81..bfc025432 100644 --- a/gitnexus/test/unit/eval-formatters.test.ts +++ b/gitnexus/test/unit/eval-formatters.test.ts @@ -93,6 +93,43 @@ describe('formatQueryResult', () => { expect(result).toContain('No matching execution flows'); }); + it('lists a shared hub under each process that has an attach (R7)', () => { + const result = formatQueryResult({ + processes: [ + { id: 'proc:A', summary: 'User Login', step_count: 3, symbol_count: 1 }, + { id: 'proc:B', summary: 'Beta Flow', step_count: 3, symbol_count: 1 }, + ], + process_symbols: [ + { + id: 'func:validate', + process_id: 'proc:A', + type: 'Function', + name: 'validate', + filePath: 'src/auth.ts', + startLine: 17, + }, + { + id: 'func:validate', + process_id: 'proc:B', + type: 'Function', + name: 'validate', + filePath: 'src/beta.ts', + startLine: 17, + }, + ], + definitions: [], + }); + expect(result).toContain('User Login (3 steps, 1 symbols)'); + expect(result).toContain('Beta Flow (3 steps, 1 symbols)'); + const sections = result.split(/\n\d+\. /); + const login = sections.find((section) => section.startsWith('User Login')); + const beta = sections.find((section) => section.startsWith('Beta Flow')); + expect(login).toContain('validate → src/auth.ts:17'); + expect(login).not.toContain('src/beta.ts'); + expect(beta).toContain('validate → src/beta.ts:17'); + expect(beta).not.toContain('src/auth.ts'); + }); + it('formats processes with symbols', () => { const result = formatQueryResult({ processes: [{ id: 'p1', summary: 'User Login Flow', step_count: 3, symbol_count: 2 }], diff --git a/gitnexus/test/unit/group/group-service-group-mode.test.ts b/gitnexus/test/unit/group/group-service-group-mode.test.ts index ebd0ee0ba..b4e6a9991 100644 --- a/gitnexus/test/unit/group/group-service-group-mode.test.ts +++ b/gitnexus/test/unit/group/group-service-group-mode.test.ts @@ -56,6 +56,97 @@ function makePort(overrides: Partial = {}): GroupToolPort { } describe('GroupService group-mode API surface', () => { + it('groupQuery with service keeps a hub-only process after pair-key attaches (AE3)', async () => { + const { tmpDir, cleanup } = makeTmpGroup(); + vi.stubEnv('GITNEXUS_HOME', tmpDir); + try { + const query = vi.fn(async () => ({ + processes: [{ id: 'proc:A' }, { id: 'proc:B' }], + process_symbols: [ + { id: 'func:validate', process_id: 'proc:A', filePath: 'services/auth/a.ts' }, + { id: 'func:validate', process_id: 'proc:B', filePath: 'services/auth/b.ts' }, + ], + })); + const svc = new GroupService(makePort({ query })); + const r = (await svc.groupQuery({ + name: 'test-group', + query: 'validate', + service: 'services/auth', + })) as { + results: Array<{ id?: string; symbol_count?: number }>; + process_symbols?: unknown; + }; + const resultIds = r.results.map((row) => row.id); + expect(resultIds).toContain('proc:A'); + expect(resultIds).toContain('proc:B'); + expect(r.results.find((row) => row.id === 'proc:A')?.symbol_count).toBe(1); + expect(r.results.find((row) => row.id === 'proc:B')?.symbol_count).toBe(1); + expect(r).not.toHaveProperty('process_symbols'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('groupQuery counts only in-prefix attaches on a kept process', async () => { + const { tmpDir, cleanup } = makeTmpGroup(); + vi.stubEnv('GITNEXUS_HOME', tmpDir); + try { + const query = vi.fn(async () => ({ + processes: [{ id: 'proc:B', symbol_count: 9 }], + process_symbols: [ + { id: 'func:a', process_id: 'proc:B', filePath: 'services/auth/a.ts' }, + { id: 'func:b', process_id: 'proc:B', filePath: 'services/auth/b.ts' }, + { id: 'func:c', process_id: 'proc:B', filePath: 'other/c.ts' }, + ], + })); + const svc = new GroupService(makePort({ query })); + const r = (await svc.groupQuery({ + name: 'test-group', + query: 'validate', + service: 'services/auth', + })) as { results: Array<{ id?: string; symbol_count?: number }> }; + expect(r.results.every((row) => row.id === 'proc:B' && row.symbol_count === 2)).toBe(true); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('groupQuery drops a hub process whose only attach is outside the service prefix', async () => { + const { tmpDir, cleanup } = makeTmpGroup(); + vi.stubEnv('GITNEXUS_HOME', tmpDir); + try { + const query = vi.fn(async () => ({ + processes: [ + { id: 'proc:A', symbol_count: 5 }, + { id: 'proc:B', symbol_count: 5 }, + ], + process_symbols: [ + { id: 'func:validate', process_id: 'proc:A', filePath: 'other/outside.ts' }, + { id: 'func:validate', process_id: 'proc:B', filePath: 'services/auth/b.ts' }, + ], + })); + const svc = new GroupService(makePort({ query })); + const r = (await svc.groupQuery({ + name: 'test-group', + query: 'validate', + service: 'services/auth', + })) as { + results: Array<{ id?: string; symbol_count?: number }>; + process_symbols?: unknown; + }; + const resultIds = r.results.map((row) => row.id); + expect(resultIds).toContain('proc:B'); + expect(resultIds).not.toContain('proc:A'); + expect(r.results.every((row) => row.id === 'proc:B' && row.symbol_count === 1)).toBe(true); + expect(r).not.toHaveProperty('process_symbols'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + it('groupQuery uses name (never @-repo) and optional service filters processes', async () => { const { tmpDir, cleanup } = makeTmpGroup(); vi.stubEnv('GITNEXUS_HOME', tmpDir); @@ -72,9 +163,10 @@ describe('GroupService group-mode API surface', () => { name: 'test-group', query: 'oauth', service: 'services/auth', - })) as { results: Array<{ id?: string }> }; + })) as { results: Array<{ id?: string; symbol_count?: number }> }; expect(query).toHaveBeenCalled(); expect(r.results.every((row) => row.id === 'p1')).toBe(true); + expect(r.results.every((row) => row.symbol_count === 1)).toBe(true); } finally { vi.unstubAllEnvs(); cleanup(); diff --git a/gitnexus/test/unit/mcp/query-process-attaches.test.ts b/gitnexus/test/unit/mcp/query-process-attaches.test.ts new file mode 100644 index 000000000..23e368303 --- /dev/null +++ b/gitnexus/test/unit/mcp/query-process-attaches.test.ts @@ -0,0 +1,179 @@ +/** + * Pair-key query attach shaping (#3351). + * + * Locks AE1 / AE2 / KTD4 without Ladybug: unique-id first-wins must not return. + */ +import { describe, expect, it } from 'vitest'; +import { + shapeQueryProcessAttaches, + type QueryProcessAttach, + type RankedQueryProcess, +} from '../../../src/mcp/local/query-process-attaches.js'; + +function attach( + id: string, + processId: string, + extras: Record = {}, +): QueryProcessAttach { + return { id, process_id: processId, ...extras }; +} + +function ranked( + id: string, + symbols: Array<{ id: string; process_id: string } & Record>, + extras: Partial = {}, +): RankedQueryProcess { + return { + id, + label: id, + priority: 1, + symbols, + ...extras, + }; +} + +describe('shapeQueryProcessAttaches', () => { + it('keeps a shared hub under every owning process (AE1)', () => { + const { processes, process_symbols } = shapeQueryProcessAttaches( + [ + ranked('proc:login-flow', [attach('func:validate', 'proc:login-flow', { step_index: 2 })]), + ranked('proc:beta-flow', [attach('func:validate', 'proc:beta-flow', { step_index: 3 })]), + ], + { maxSymbolsPerProcess: 25 }, + ); + + const pairs = process_symbols + .filter((s) => s.id === 'func:validate') + .map((s) => [s.process_id, s.step_index]); + expect(pairs).toEqual([ + ['proc:login-flow', 2], + ['proc:beta-flow', 3], + ]); + expect(processes.find((p) => p.id === 'proc:login-flow')?.symbol_count).toBe(1); + expect(processes.find((p) => p.id === 'proc:beta-flow')?.symbol_count).toBe(1); + }); + + it('sets each process symbol_count from emitted attaches, not pre-slice hits (AE2)', () => { + const hits = Array.from({ length: 40 }, (_, i) => + attach(`func:hit-${i}`, 'proc:fat', { step_index: i }), + ); + const { processes, process_symbols } = shapeQueryProcessAttaches([ranked('proc:fat', hits)], { + maxSymbolsPerProcess: 25, + }); + + expect(process_symbols).toHaveLength(25); + expect(process_symbols.map((s) => s.id)).toEqual( + Array.from({ length: 25 }, (_, i) => `func:hit-${i}`), + ); + expect(process_symbols.every((s) => s.process_id === 'proc:fat')).toBe(true); + expect(processes[0]?.symbol_count).toBe(25); + }); + + it('keeps pairs that a colon join would collapse', () => { + const { process_symbols } = shapeQueryProcessAttaches( + [ + ranked('login-flow', [attach('func:validate:proc', 'login-flow')]), + ranked('proc:login-flow', [attach('func:validate', 'proc:login-flow')]), + ], + { maxSymbolsPerProcess: 25 }, + ); + + expect(process_symbols.map((s) => [s.id, s.process_id])).toEqual([ + ['func:validate:proc', 'login-flow'], + ['func:validate', 'proc:login-flow'], + ]); + }); + + it('keeps the first row when the same pair appears twice (KTD4)', () => { + const { processes, process_symbols } = shapeQueryProcessAttaches( + [ + ranked('proc:login-flow', [ + attach('func:validate', 'proc:login-flow', { step_index: 2, content: 'first' }), + attach('func:validate', 'proc:login-flow', { step_index: 99, content: 'second' }), + ]), + ], + { maxSymbolsPerProcess: 25 }, + ); + + const rows = process_symbols.filter((s) => s.id === 'func:validate'); + expect(rows).toHaveLength(1); + expect(rows[0]?.step_index).toBe(2); + expect(rows[0]?.content).toBe('first'); + expect(processes[0]?.symbol_count).toBe(1); + }); + + it('keeps include_content on every emitted hub attach (KTD5)', () => { + const { process_symbols } = shapeQueryProcessAttaches( + [ + ranked('proc:login-flow', [ + attach('func:validate', 'proc:login-flow', { content: 'function validate() {}' }), + ]), + ranked('proc:beta-flow', [ + attach('func:validate', 'proc:beta-flow', { content: 'function validate() {}' }), + ]), + ], + { maxSymbolsPerProcess: 25 }, + ); + + expect(process_symbols.map((s) => s.content)).toEqual([ + 'function validate() {}', + 'function validate() {}', + ]); + }); + + it('marks the entry-point hit and preserves process card extras', () => { + const chain = [{ direction: 'downstream' }]; + const { processes, process_symbols } = shapeQueryProcessAttaches( + [ + ranked('proc:login-flow', [attach('func:login', 'proc:login-flow')], { + heuristicLabel: 'User Login', + processType: 'intra_community', + stepCount: 2, + entryPointId: 'func:login', + priority: 1.23456, + routes: [{ url: '/login', method: 'POST' }], + }), + ], + { maxSymbolsPerProcess: 25, chainByProcessId: new Map([['proc:login-flow', chain]]) }, + ); + + expect(process_symbols[0]?.is_entry_point).toBe(true); + + const mixed = shapeQueryProcessAttaches( + [ + ranked( + 'proc:login-flow', + [attach('func:login', 'proc:login-flow'), attach('func:validate', 'proc:login-flow')], + { entryPointId: 'func:login' }, + ), + ranked('proc:beta-flow', [attach('func:login', 'proc:beta-flow')], { + entryPointId: 'func:beta', + }), + ], + { maxSymbolsPerProcess: 25 }, + ); + const flagged = mixed.process_symbols.filter((s) => s.is_entry_point === true); + expect(flagged).toEqual([ + expect.objectContaining({ id: 'func:login', process_id: 'proc:login-flow' }), + ]); + expect( + mixed.process_symbols + .filter((s) => s.is_entry_point !== true) + .map((s) => [s.id, s.process_id]), + ).toEqual([ + ['func:validate', 'proc:login-flow'], + ['func:login', 'proc:beta-flow'], + ]); + expect(processes[0]).toMatchObject({ + id: 'proc:login-flow', + summary: 'User Login', + priority: 1.235, + symbol_count: 1, + process_type: 'intra_community', + step_count: 2, + route: '/login', + method: 'POST', + chain, + }); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index e08693700..aba1c7fd8 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -124,6 +124,20 @@ describe('GITNEXUS_TOOLS', () => { } }); + it('query process_symbols description names the (id, process_id) join key (#3351)', () => { + const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!; + expect(queryTool.description).toContain('One row per (id, process_id)'); + expect(queryTool.description).toContain('Join a process to its rows by process_id'); + expect(queryTool.description).toContain('symbol_count is the number of those rows'); + expect(queryTool.description).toContain('single-repo envelope'); + expect(queryTool.description).toContain('does not include process_symbols'); + expect(queryTool.description).toContain('Join processes[].id to process_symbols[].process_id'); + expect(queryTool.description).toContain( + 'when service is set, it counts only attaches under that prefix', + ); + expect(queryTool.description).toContain('query again with repo "@/"'); + }); + it('query tool requires "search_query" parameter (renamed from "query" for #2175)', () => { const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!; expect(queryTool.inputSchema.required).toContain('search_query');