From 6444e077ff1d4e957128e5ac83258a802941d8c7 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:00:49 +0300 Subject: [PATCH 01/46] fix: preserve unified search partial result state Project the backend partialResults boolean into initial and stored status result envelopes so consumers can distinguish interim snapshots from partial evidence. Keep progress-only responses snapshot-free and cover both boolean values across all result-bearing paths. --- .../shared/unified-search-response.test.ts | 140 ++++++++++++++++++ .../mcp/src/shared/unified-search-response.ts | 7 + .../shared/unified-search-status-text.test.ts | 7 + .../src/shared/unified-search-text.test.ts | 1 + 4 files changed, 155 insertions(+) diff --git a/packages/mcp/src/shared/unified-search-response.test.ts b/packages/mcp/src/shared/unified-search-response.test.ts index ab47ace5..9755efba 100644 --- a/packages/mcp/src/shared/unified-search-response.test.ts +++ b/packages/mcp/src/shared/unified-search-response.test.ts @@ -85,6 +85,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { ); expect(payload.completed).toBe(true); + expect(payload.partialResults).toBe(false); expect(payload.results.length).toBe(1); expect(payload.results[0]).toMatchObject({ type: "repository_code", @@ -222,6 +223,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { next: 'search_status search_ref="search-ref-123" wait_timeout_ms=20000', }, }); + expect(payload).not.toHaveProperty("partialResults"); }); it("normalises incomplete outcomes with opt-in partial results", () => { @@ -256,10 +258,68 @@ describe("buildUnifiedSearchSuccessPayload", () => { expect(payload.completed).toBe(false); expect(payload.query.allowPartialResults).toBe(true); + expect(payload.partialResults).toBe(true); expect(payload.results.length).toBe(1); expect(payload.results[0]?.target).toBe("npm:express@4.18.2"); }); + it("preserves false on an incomplete result snapshot", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const payload = buildUnifiedSearchSuccessPayload( + params, + "router middleware", + "router middleware", + { + state: "incomplete", + completed: false, + searchRef: "search-ref-interim", + result: { + ...defaultUnifiedSearchOutcome.result, + partialResults: false, + }, + progress: { + searchRef: "search-ref-interim", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 1, + elapsedMs: 200, + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + }, + }, + ); + + expect(payload.completed).toBe(false); + if (payload.completed) throw new Error("expected incomplete payload"); + expect(payload.partialResults).toBe(false); + }); + + it("preserves true on a completed initial result", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const payload = buildUnifiedSearchSuccessPayload( + params, + "router middleware", + "router middleware", + { + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + partialResults: true, + }, + }, + ); + + expect(payload.completed).toBe(true); + expect(payload.partialResults).toBe(true); + }); + it("preserves terminal deferred evidence and directs a later new search", () => { if (defaultUnifiedSearchOutcome.state !== "completed") { throw new Error("expected completed outcome fixture"); @@ -2276,6 +2336,7 @@ describe("buildUnifiedSearchStatusPayload", () => { next: 'search_status search_ref="search-ref-123" wait_timeout_ms=20000', }, }); + expect(payload).not.toHaveProperty("partialResults"); }); it("builds a completed status payload without fabricating the original request", () => { @@ -2298,6 +2359,7 @@ describe("buildUnifiedSearchStatusPayload", () => { sources: ["code"], }, sources: ["code"], + partialResults: false, hasMore: false, results: [ expect.objectContaining({ @@ -2308,6 +2370,84 @@ describe("buildUnifiedSearchStatusPayload", () => { }); }); + it("preserves false on an incomplete status result", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const payload = buildUnifiedSearchStatusPayload({ + state: "incomplete", + completed: false, + searchRef: "search-ref-interim", + progress: { + searchRef: "search-ref-interim", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 1, + elapsedMs: 200, + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + }, + result: { + ...defaultUnifiedSearchOutcome.result, + partialResults: false, + }, + }); + + expect(payload.completed).toBe(false); + if (payload.completed) throw new Error("expected incomplete payload"); + expect(payload.result?.partialResults).toBe(false); + }); + + it("preserves true on a completed status result", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const payload = buildUnifiedSearchStatusPayload({ + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + partialResults: true, + }, + }); + + expect(payload.completed).toBe(true); + if (!payload.completed) throw new Error("expected completed payload"); + expect(payload.result.partialResults).toBe(true); + }); + + it("preserves partial results on incomplete status payloads", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const payload = buildUnifiedSearchStatusPayload({ + state: "incomplete", + completed: false, + searchRef: "search-ref-partial", + progress: { + searchRef: "search-ref-partial", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 1, + elapsedMs: 200, + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + }, + result: { + ...defaultUnifiedSearchOutcome.result, + partialResults: true, + }, + }); + + expect(payload.completed).toBe(false); + if (payload.completed) throw new Error("expected incomplete payload"); + expect(payload.result?.partialResults).toBe(true); + }); + it.each(["DEFERRED", "FAILED", "TIMEOUT"] as const)( "replaces status polling for a terminal %s session", (status) => { diff --git a/packages/mcp/src/shared/unified-search-response.ts b/packages/mcp/src/shared/unified-search-response.ts index 2d3f48e2..50bc4029 100644 --- a/packages/mcp/src/shared/unified-search-response.ts +++ b/packages/mcp/src/shared/unified-search-response.ts @@ -193,6 +193,7 @@ export interface LeanDocCoverage { export interface UnifiedSearchCompletedPayload { query: UnifiedSearchQueryEcho; completed: true; + partialResults: boolean; hasMore: boolean; nextOffset?: number; results: UnifiedSearchHitPayload[]; @@ -212,6 +213,8 @@ export interface UnifiedSearchCompletedPayload { export interface UnifiedSearchIncompletePayload { query: UnifiedSearchQueryEcho; completed: false; + /** Present only when the response includes a result snapshot. */ + partialResults?: boolean; hasMore: boolean; nextOffset?: number; results: UnifiedSearchHitPayload[]; @@ -231,6 +234,7 @@ export interface UnifiedSearchErrorPayload { export interface UnifiedSearchStatusResultPayload { query?: UnifiedSearchQueryEcho; + partialResults: boolean; warnings?: string[]; sources?: string[]; hasMore: boolean; @@ -278,6 +282,7 @@ export function buildUnifiedSearchSuccessPayload( results: result?.results.map(buildHitPayload) ?? [], searchRef: outcome.searchRef, }; + if (result) payload.partialResults = result.partialResults; if (result?.page.hasMore === true) { payload.nextOffset = result.page.offset + result.page.returned; } @@ -303,6 +308,7 @@ export function buildUnifiedSearchSuccessPayload( const completed: UnifiedSearchCompletedPayload = { query, completed: true, + partialResults: outcome.result.partialResults, hasMore: outcome.result.page.hasMore, results: outcome.result.results.map(buildHitPayload), }; @@ -415,6 +421,7 @@ function buildUnifiedSearchStatusResultPayload( ): UnifiedSearchStatusResultPayload { const payload: UnifiedSearchStatusResultPayload = { query: buildStatusQueryEcho(result), + partialResults: result.partialResults, hasMore: result.page.hasMore, results: result.results.map(buildHitPayload), }; diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index f817dbc6..972dbb17 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -13,6 +13,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: true, searchRef: "search-ref-docs", result: { + partialResults: false, hasMore: false, results: [], evidenceNotice: notice, @@ -62,6 +63,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: true, searchRef: "search-ref-healthy", result: { + partialResults: false, hasMore: false, results: [], sourceStatus: [ @@ -85,6 +87,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: true, searchRef: "search-ref-unsearched", result: { + partialResults: false, hasMore: false, results: [], sourceStatus: [ @@ -117,6 +120,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: true, searchRef: "search-ref-healthy", result: { + partialResults: false, hasMore: false, results: [ { @@ -159,6 +163,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: true, searchRef: "search-ref-evidence", result: { + partialResults: false, hasMore: false, evidenceNotice: "Results may change after pending work completes.", results: [ @@ -185,6 +190,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: true, searchRef: "search-ref-evidence", result: { + partialResults: false, hasMore: false, evidenceNotice: "Results may change after pending work completes.", results: [], @@ -204,6 +210,7 @@ describe("renderUnifiedSearchStatusText", () => { completed: false, searchRef: "search-ref-incomplete", result: { + partialResults: false, hasMore: false, results: [ { diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index fa5e8947..750dd7ad 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -74,6 +74,7 @@ function completed( return { query: { raw: "diff myers" }, completed: true, + partialResults: false, hasMore: false, results, ...overrides, From 22690262be224d2d011c229125fc0bdc5861c2b2 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:12:29 +0300 Subject: [PATCH 02/46] feat: add unified search presentation model Project lifecycle, availability, trust, source readiness, bounded alternatives, warnings, and primary actions from structured search payloads. Keep the projection pure and free of renderer prose, ANSI styling, and backend calls. --- .../unified-search-presentation.test.ts | 729 ++++++++++++++++ .../src/shared/unified-search-presentation.ts | 812 ++++++++++++++++++ 2 files changed, 1541 insertions(+) create mode 100644 packages/mcp/src/shared/unified-search-presentation.test.ts create mode 100644 packages/mcp/src/shared/unified-search-presentation.ts diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts new file mode 100644 index 00000000..07f13f2b --- /dev/null +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -0,0 +1,729 @@ +import { describe, expect, it } from "bun:test"; +import { projectUnifiedSearchPresentation } from "./unified-search-presentation.js"; +import type { + UnifiedSearchCompletedPayload, + UnifiedSearchIncompletePayload, + UnifiedSearchSourceStatusPayload, + UnifiedSearchStatusCompletedPayload, + UnifiedSearchStatusIncompletePayload, + UnifiedSearchStatusResultPayload, +} from "./unified-search-response.js"; + +const hit = { + type: "repository_code", + target: "npm:express@4.18.2", + title: "router", + summary: "router implementation", + locator: { packageName: "express", version: "4.18.2" }, +}; + +function completed( + overrides: Partial = {}, +): UnifiedSearchCompletedPayload { + return { + query: { raw: "router" }, + completed: true, + partialResults: false, + hasMore: false, + results: [hit], + ...overrides, + }; +} + +function incomplete( + overrides: Partial = {}, +): UnifiedSearchIncompletePayload { + return { + query: { raw: "router" }, + completed: false, + hasMore: false, + results: [], + searchRef: "search-ref-1", + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }, + ...overrides, + }; +} + +function statusResult( + overrides: Partial = {}, +): UnifiedSearchStatusResultPayload { + return { + query: { raw: "router" }, + partialResults: false, + hasMore: false, + results: [], + ...overrides, + }; +} + +function statusCompleted( + result: UnifiedSearchStatusResultPayload = statusResult(), +): UnifiedSearchStatusCompletedPayload { + return { completed: true, searchRef: "search-ref-1", result }; +} + +function statusIncomplete( + overrides: Partial = {}, +): UnifiedSearchStatusIncompletePayload { + return { + completed: false, + searchRef: "search-ref-1", + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }, + ...overrides, + }; +} + +function source( + overrides: Partial = {}, +): UnifiedSearchSourceStatusPayload { + return { + source: "code", + targetLabel: "npm:express@4.18.2", + ...overrides, + }; +} + +describe("projectUnifiedSearchPresentation", () => { + it.each([ + ["PENDING", "preparing"], + ["INDEXING", "indexing"], + ["SEARCHING", "searching"], + ] as const)("keeps active lifecycle %s distinct", (status, kind) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }, + }), + ); + + expect(presentation.lifecycle).toEqual({ kind: "active", status }); + expect(presentation.lifecycleHeadline).toBe(kind); + expect(presentation.progress).toEqual({ + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }); + expect(presentation.action).toEqual({ + kind: "poll", + searchRef: "search-ref-1", + }); + }); + + it.each(["DEFERRED", "TIMEOUT", "FAILED"] as const)( + "keeps terminal lifecycle %s distinct and non-polling", + (status) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + }), + ); + + expect(presentation.lifecycle).toEqual({ kind: "terminal", status }); + expect(presentation.action).toEqual({ kind: "new_search" }); + }, + ); + + it("preserves an unknown raw lifecycle without polling", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "FUTURE_SESSION_STATE", + targetsReady: 1, + targetsTotal: 2, + elapsedMs: 60_000, + }, + }), + ); + + expect(presentation.lifecycle).toEqual({ + kind: "unknown", + status: "FUTURE_SESSION_STATE", + }); + expect(presentation.action).toEqual({ kind: "new_search" }); + }); + + it("classifies completed current hits as final", () => { + const presentation = projectUnifiedSearchPresentation(completed()); + + expect(presentation.availability).toEqual({ + kind: "final", + hasSnapshot: true, + resultCount: 1, + }); + expect(presentation.lifecycle).toEqual({ + kind: "completed", + status: "COMPLETED", + }); + expect(presentation.action).toEqual({ kind: "none" }); + }); + + it("classifies an empty searched snapshot and eligible pivots", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ results: [], sourceStatus: [source({ resultCount: 0 })] }), + ); + + expect(presentation.availability).toEqual({ + kind: "empty", + hasSnapshot: true, + resultCount: 0, + }); + expect(presentation.sources).toEqual([ + { + kind: "code", + entries: [ + { + state: "searched", + target: "npm:express@4.18.2", + resultCount: 0, + }, + ], + }, + ]); + expect(presentation.action).toEqual({ + kind: "query_rewrite", + rewrite: "shorter_or_broader", + }); + }); + + it.each([ + ["PENDING", "no_snapshot"], + ["INDEXING", "no_snapshot"], + ["SEARCHING", "no_snapshot"], + ] as const)("classifies %s progress-only responses", (status, kind) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + requestedSources: ["code"], + }, + }), + ); + + expect(presentation.availability.kind).toBe(kind); + expect(presentation.availability.hasSnapshot).toBe(false); + expect(presentation.sources).toEqual([]); + expect(presentation.progress).toEqual({ + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + requestedSources: ["code"], + }); + expect(presentation.action.kind).toBe("poll"); + }); + + it.each([ + ["PENDING", false, "interim"], + ["PENDING", true, "partial"], + ["INDEXING", false, "interim"], + ["INDEXING", true, "partial"], + ["SEARCHING", false, "interim"], + ["SEARCHING", true, "partial"], + ] as const)( + "classifies %s snapshot with partialResults=%s as %s", + (status, partialResults, kind) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults, + results: [hit], + progress: { + status, + targetsReady: 1, + targetsTotal: 1, + elapsedMs: 200, + }, + }), + ); + + expect(presentation.availability).toEqual({ + kind, + hasSnapshot: true, + resultCount: 1, + }); + }, + ); + + it("classifies stored result snapshots with the same availability rules", () => { + const interim = projectUnifiedSearchPresentation( + statusIncomplete({ result: statusResult({ results: [hit] }) }), + ); + const partial = projectUnifiedSearchPresentation( + statusIncomplete({ + result: statusResult({ partialResults: true, results: [hit] }), + }), + ); + const completedPartial = projectUnifiedSearchPresentation( + statusCompleted(statusResult({ partialResults: true, results: [hit] })), + ); + + expect(interim.availability.kind).toBe("interim"); + expect(partial.availability.kind).toBe("partial"); + expect(completedPartial.availability.kind).toBe("partial"); + }); + + it("classifies a progress-only status without a result snapshot", () => { + const presentation = projectUnifiedSearchPresentation(statusIncomplete()); + + expect(presentation.availability).toEqual({ + kind: "no_snapshot", + hasSnapshot: false, + resultCount: 0, + }); + expect(presentation.sources).toEqual([]); + }); + + it("groups searched, waiting, and available documentation contributors", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:express@5.1.0", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/expressjs/express", + }, + { + kind: "DOCPACK", + state: "PENDING", + resultCount: 0, + siteKey: "expressjs.com", + }, + { + kind: "DOCPACK", + state: "READY", + resultCount: 0, + siteKey: "api.example.com", + siteUrl: "https://api.example.com/reference", + }, + ], + }), + ], + }), + ); + + expect(presentation.sources).toEqual([ + { + kind: "repository_docs", + entries: [ + { + state: "searched", + target: "npm:express@5.1.0", + resultCount: 1, + }, + ], + }, + { + kind: "site_docs", + entries: [ + { state: "waiting", target: "npm:express@5.1.0", resultCount: 0 }, + { + state: "available_not_searched", + target: "https://api.example.com/reference", + resultCount: 0, + }, + ], + }, + ]); + expect( + presentation.trustLimits.filter((limit) => limit.kind === "source"), + ).toEqual([ + { + kind: "source", + state: "waiting", + source: "site_docs", + target: "npm:express@5.1.0", + }, + { + kind: "source", + state: "available_not_searched", + source: "site_docs", + target: "https://api.example.com/reference", + }, + ]); + }); + + it("keeps progress-only source status empty while projecting target readiness", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + requestedSources: ["CODE", "DOCS"], + targets: [ + { + requested: "npm:n8n@2.36.7", + freshness: "INDEXING", + indexingRef: "idx-hidden", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + ], + }, + ], + }, + }), + ); + + expect(presentation.sources).toEqual([]); + expect(presentation.trustLimits).toEqual([]); + expect(presentation.alternatives).toEqual([ + { + target: "npm:n8n@2.36.7", + versions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + ], + versionsRemaining: 0, + refs: [], + refsRemaining: 0, + suggestedRefs: [], + suggestedRefsRemaining: 0, + }, + ]); + }); + + it("keeps multiple target alternatives in backend order", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 2, + elapsedMs: 200, + targets: [ + { + requested: "npm:express latest", + availableVersions: [{ version: "4.18.2", ref: "v4.18.2" }], + }, + { + requested: "github:expressjs/express#main", + availableRefs: [{ ref: "main" }], + }, + ], + }, + }), + ); + + expect(presentation.alternatives).toEqual([ + expect.objectContaining({ + target: "npm:express latest", + versions: [{ version: "4.18.2", ref: "v4.18.2" }], + }), + expect.objectContaining({ + target: "github:expressjs/express#main", + refs: [{ ref: "main" }], + }), + ]); + }); + + it("projects the supplied n8n active empty snapshot without raw diagnostics", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + results: [], + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:n8n@2.36.7", + targetResolution: { + freshness: "indexing", + freshnessReason: "latest_version_indexing", + indexingRef: "indexing-ref-hidden", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + { version: "2.22.6", ref: "v2.22.6" }, + ], + availableRefs: [{ ref: "HEAD" }, { ref: "master" }], + }, + contributors: [ + { + kind: "DOCPACK", + state: "READY", + resultCount: 0, + siteKey: "n8n.io", + siteUrl: "https://n8n.io", + coverage: { + coverageState: "CAPPED", + pagesCrawled: 1480, + }, + }, + { + kind: "REPOSITORY_DOCS", + state: "PENDING", + resultCount: 0, + repositoryUrl: "https://github.com/n8n-io/n8n", + }, + ], + }), + ], + evidenceNotice: "Opaque evidence notice.", + }), + ); + + expect(presentation.availability.kind).toBe("empty"); + expect(presentation.lifecycle).toMatchObject({ + kind: "active", + status: "INDEXING", + }); + expect(presentation.alternatives[0]?.versions).toHaveLength(3); + expect(presentation.alternatives[0]?.versionsRemaining).toBe(1); + expect(presentation.alternatives[0]?.refs).toEqual([ + { ref: "HEAD" }, + { ref: "master" }, + ]); + expect(presentation.trustLimits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "source", state: "waiting" }), + expect.objectContaining({ + kind: "source", + state: "available_not_searched", + }), + expect.objectContaining({ kind: "coverage", state: "capped" }), + expect.objectContaining({ kind: "mutable_evidence" }), + ]), + ); + expect(JSON.stringify(presentation)).not.toContain("indexingRef"); + expect(JSON.stringify(presentation)).not.toContain( + "latest_version_indexing", + ); + expect(presentation.action).toEqual({ + kind: "poll", + searchRef: "search-ref-1", + }); + }); + + it("classifies stale, fallback, and provisional trust limits", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [ + { + ...hit, + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + freshness: "STALE", + }, + ], + sourceStatus: [ + source({ + codeIndexState: "PROVISIONAL", + targetResolution: { + freshness: "fallback_recent", + availableVersions: [], + availableRefs: [], + served: { + registry: "npm", + packageName: "express", + version: "5.1.0", + }, + }, + }), + ], + }), + ); + + expect(presentation.trustLimits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "stale", + servedTarget: "npm:express@5.1.0", + }), + expect.objectContaining({ + kind: "provisional", + target: "npm:express@4.18.2", + }), + expect.objectContaining({ + kind: "stale", + target: "npm:express@4.18.2", + }), + ]), + ); + }); + + it("classifies coverage and structured query constraints without promoted warnings", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + query: { + raw: "router", + warnings: ["unknown qualifier"], + filters: { kind: "function", publicOnly: true }, + }, + results: [], + warnings: ["Source 'code' is indexing"], + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:expressjs.com", + coverage: { coverageState: "PARTIAL", pagesCrawled: 42 }, + ignoredFilters: ["category"], + incompatibleQueryFeatures: ["exact_name"], + }), + ], + }), + ); + + expect(presentation.warnings).toEqual([ + { kind: "query", message: "unknown qualifier" }, + { + kind: "ignored_filter", + source: "site:expressjs.com", + values: ["category"], + }, + { + kind: "incompatible_query_feature", + source: "site:expressjs.com", + values: ["exact_name"], + }, + ]); + expect(presentation.trustLimits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "coverage", state: "partial" }), + ]), + ); + expect(JSON.stringify(presentation)).not.toContain( + "Source 'code' is indexing", + ); + expect(presentation.action).toEqual({ kind: "none" }); + }); + + it("suppresses generic pivots for evidence limits and prefers indexed alternatives", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + query: { raw: "router", filters: { kind: "function" } }, + results: [], + sourceStatus: [ + source({ + codeIndexState: "INDEXING", + targetResolution: { + freshness: "indexing", + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ + kind: "indexed_alternative", + target: "npm:express@4.18.2", + category: "version", + value: "4.17.0", + }); + }); + + it("allows only a shorter/broader pivot for a standalone site", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + query: { raw: "router", sources: ["docs"] }, + results: [], + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:expressjs.com", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ + kind: "query_rewrite", + rewrite: "site_shorter_or_broader", + }); + }); + + it("only exposes filter and symbol pivots when the request makes them applicable", () => { + const filtered = projectUnifiedSearchPresentation( + completed({ + query: { raw: "router", filters: { kind: "function" } }, + results: [], + }), + ); + const symbol = projectUnifiedSearchPresentation( + completed({ + query: { raw: "router", sources: ["symbol"] }, + results: [], + }), + ); + + expect(filtered.action).toEqual({ + kind: "query_rewrite", + rewrite: "remove_filters", + }); + expect(symbol.action).toEqual({ + kind: "query_rewrite", + rewrite: "shorter_or_broader", + }); + }); + + it("bounds alternatives in backend order and counts remaining values", () => { + const versions = Array.from({ length: 5 }, (_, index) => ({ + version: `1.${index}.0`, + ref: `v1.${index}.0`, + })); + const refs = Array.from({ length: 5 }, (_, index) => ({ + ref: `ref-${index}`, + })); + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + targets: [ + { + requested: "npm:express latest", + availableVersions: versions, + availableRefs: refs, + suggestedRefs: refs, + }, + ], + }, + }), + ); + + expect(presentation.alternatives).toEqual([ + { + target: "npm:express latest", + versions: versions.slice(0, 3), + versionsRemaining: 2, + refs: refs.slice(0, 3), + refsRemaining: 2, + suggestedRefs: refs.slice(0, 3), + suggestedRefsRemaining: 2, + }, + ]); + }); +}); diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts new file mode 100644 index 00000000..270737fa --- /dev/null +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -0,0 +1,812 @@ +import type { + UnifiedSearchCompletedPayload, + UnifiedSearchHitPayload, + UnifiedSearchIncompletePayload, + UnifiedSearchProgressPayload, + UnifiedSearchQueryEcho, + UnifiedSearchSourceStatusPayload, + UnifiedSearchStatusCompletedPayload, + UnifiedSearchStatusIncompletePayload, + UnifiedSearchStatusResultPayload, +} from "./unified-search-response.js"; + +export type UnifiedSearchPresentationInput = + | UnifiedSearchCompletedPayload + | UnifiedSearchIncompletePayload + | UnifiedSearchStatusCompletedPayload + | UnifiedSearchStatusIncompletePayload; + +export type UnifiedSearchAvailabilityKind = + | "no_snapshot" + | "empty" + | "interim" + | "partial" + | "final"; + +export interface UnifiedSearchAvailability { + kind: UnifiedSearchAvailabilityKind; + hasSnapshot: boolean; + resultCount: number; +} + +export type UnifiedSearchActiveStatus = "PENDING" | "INDEXING" | "SEARCHING"; +export type UnifiedSearchTerminalStatus = "DEFERRED" | "TIMEOUT" | "FAILED"; + +export type UnifiedSearchLifecycle = + | { kind: "active"; status: UnifiedSearchActiveStatus } + | { kind: "completed"; status: "COMPLETED" } + | { kind: "terminal"; status: UnifiedSearchTerminalStatus } + | { kind: "unknown"; status?: string }; + +export type UnifiedSearchSourceKind = "code" | "repository_docs" | "site_docs"; +export type UnifiedSearchSourceReadiness = + | "searched" + | "waiting" + | "available_not_searched" + | "unavailable"; + +export interface UnifiedSearchSourceEntry { + state: UnifiedSearchSourceReadiness; + target: string; + resultCount?: number; +} + +export interface UnifiedSearchSourceGroup { + kind: UnifiedSearchSourceKind; + entries: UnifiedSearchSourceEntry[]; +} + +export interface UnifiedSearchProgressPresentation { + targetsReady: number; + targetsTotal: number; + elapsedMs: number; + requestedSources?: string[]; +} + +export interface UnifiedSearchAlternative { + version?: string; + ref: string; +} + +export interface UnifiedSearchAlternativeFacts { + target?: string; + versions: UnifiedSearchAlternative[]; + versionsRemaining: number; + refs: UnifiedSearchAlternative[]; + refsRemaining: number; + suggestedRefs: UnifiedSearchAlternative[]; + suggestedRefsRemaining: number; +} + +export type UnifiedSearchConstraintKind = + | "ignored_filter" + | "incompatible_filter" + | "ignored_query_feature" + | "incompatible_query_feature"; + +export type UnifiedSearchTrustLimit = + | { + kind: "stale"; + target?: string; + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; + } + | { kind: "provisional"; target?: string } + | { + kind: "source"; + source: UnifiedSearchSourceKind; + state: Exclude; + target?: string; + } + | { + kind: "coverage"; + source: UnifiedSearchSourceKind; + state: "partial" | "capped"; + target?: string; + pagesCrawled?: number; + frontierRemaining?: number; + estimatedTotalPages?: number; + } + | { + kind: "constraint"; + constraint: UnifiedSearchConstraintKind; + source?: string; + values: string[]; + } + | { kind: "mutable_evidence" }; + +export type UnifiedSearchWarning = + | { kind: "query"; message: string } + | { + kind: UnifiedSearchConstraintKind; + source?: string; + values: string[]; + }; + +export type UnifiedSearchAction = + | { kind: "poll"; searchRef: string } + | { kind: "new_search" } + | { + kind: "indexed_alternative"; + target?: string; + category: "version" | "ref"; + value: string; + } + | { + kind: "query_rewrite"; + rewrite: + | "shorter_or_broader" + | "remove_filters" + | "symbol" + | "site_shorter_or_broader"; + } + | { kind: "none" }; + +export interface UnifiedSearchPresentation { + availability: UnifiedSearchAvailability; + lifecycle: UnifiedSearchLifecycle; + lifecycleHeadline: "preparing" | "indexing" | "searching" | undefined; + query?: UnifiedSearchQueryEcho; + searchRef?: string; + progress?: UnifiedSearchProgressPresentation; + hasMore: boolean; + sources: UnifiedSearchSourceGroup[]; + trustLimits: UnifiedSearchTrustLimit[]; + warnings: UnifiedSearchWarning[]; + alternatives: UnifiedSearchAlternativeFacts[]; + action: UnifiedSearchAction; +} + +interface SnapshotFacts { + query?: UnifiedSearchQueryEcho; + partialResults: boolean; + hasMore: boolean; + results: UnifiedSearchHitPayload[]; + sourceStatus?: UnifiedSearchSourceStatusPayload[]; + evidenceNotice?: string; +} + +interface ProgressFacts { + progress?: UnifiedSearchProgressPayload; +} + +interface CandidateSet { + target?: string; + versions: UnifiedSearchAlternative[]; + refs: UnifiedSearchAlternative[]; + suggestedRefs: UnifiedSearchAlternative[]; +} + +const MAX_ALTERNATIVES = 3; + +export function projectUnifiedSearchPresentation( + payload: UnifiedSearchPresentationInput, +): UnifiedSearchPresentation { + const snapshot = extractSnapshot(payload); + const progress = extractProgress(payload); + const lifecycle = projectLifecycle(payload, progress.progress); + const availability = projectAvailability(snapshot, lifecycle); + const sourceStatus = snapshot?.sourceStatus; + const sources = projectSources(sourceStatus); + const trustLimits = projectTrustLimits(snapshot, sources, sourceStatus); + const warnings = projectWarnings(snapshot?.query, sourceStatus); + const alternatives = projectAlternatives(progress.progress, sourceStatus); + + return { + availability, + lifecycle, + lifecycleHeadline: lifecycleHeadline(lifecycle), + query: snapshot?.query ?? extractQuery(payload), + searchRef: extractSearchRef(payload), + progress: projectProgress(progress.progress), + hasMore: snapshot?.hasMore ?? false, + sources, + trustLimits, + warnings, + alternatives, + action: projectAction({ + payload, + snapshot, + progress: progress.progress, + lifecycle, + availability, + sources, + trustLimits, + alternatives, + }), + }; +} + +function extractSnapshot( + payload: UnifiedSearchPresentationInput, +): SnapshotFacts | undefined { + if ("result" in payload) return payload.result; + if (payload.completed) { + return { + query: payload.query, + partialResults: payload.partialResults, + hasMore: payload.hasMore, + results: payload.results, + sourceStatus: payload.sourceStatus, + evidenceNotice: payload.evidenceNotice, + }; + } + if ("partialResults" in payload && payload.partialResults !== undefined) { + return { + query: payload.query, + partialResults: payload.partialResults, + hasMore: payload.hasMore, + results: payload.results, + sourceStatus: payload.sourceStatus, + evidenceNotice: payload.evidenceNotice, + }; + } + return undefined; +} + +function extractProgress( + payload: UnifiedSearchPresentationInput, +): ProgressFacts { + return "progress" in payload ? { progress: payload.progress } : {}; +} + +function extractQuery( + payload: UnifiedSearchPresentationInput, +): UnifiedSearchQueryEcho | undefined { + if ("query" in payload) return payload.query; + return undefined; +} + +function extractSearchRef( + payload: UnifiedSearchPresentationInput, +): string | undefined { + return "searchRef" in payload ? payload.searchRef : undefined; +} + +function projectProgress( + progress: UnifiedSearchProgressPayload | undefined, +): UnifiedSearchProgressPresentation | undefined { + if (!progress) return undefined; + return { + targetsReady: progress.targetsReady, + targetsTotal: progress.targetsTotal, + elapsedMs: progress.elapsedMs, + ...(progress.requestedSources?.length + ? { + requestedSources: progress.requestedSources.map((source) => + source.toLowerCase(), + ), + } + : {}), + }; +} + +function projectLifecycle( + payload: UnifiedSearchPresentationInput, + progress: UnifiedSearchProgressPayload | undefined, +): UnifiedSearchLifecycle { + if (payload.completed) return { kind: "completed", status: "COMPLETED" }; + const status = progress?.status; + switch (status) { + case "PENDING": + case "INDEXING": + case "SEARCHING": + return { kind: "active", status }; + case "DEFERRED": + case "TIMEOUT": + case "FAILED": + return { kind: "terminal", status }; + default: + return { kind: "unknown", status }; + } +} + +function lifecycleHeadline( + lifecycle: UnifiedSearchLifecycle, +): "preparing" | "indexing" | "searching" | undefined { + if (lifecycle.kind !== "active") return undefined; + switch (lifecycle.status) { + case "PENDING": + return "preparing"; + case "INDEXING": + return "indexing"; + case "SEARCHING": + return "searching"; + } +} + +function projectAvailability( + snapshot: SnapshotFacts | undefined, + lifecycle: UnifiedSearchLifecycle, +): UnifiedSearchAvailability { + if (!snapshot) { + return { kind: "no_snapshot", hasSnapshot: false, resultCount: 0 }; + } + const resultCount = snapshot.results.length; + if (resultCount === 0) { + return { kind: "empty", hasSnapshot: true, resultCount }; + } + if (snapshot.partialResults) { + return { kind: "partial", hasSnapshot: true, resultCount }; + } + return { + kind: lifecycle.kind === "active" ? "interim" : "final", + hasSnapshot: true, + resultCount, + }; +} + +function projectSources( + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): UnifiedSearchSourceGroup[] { + if (!sourceStatus) return []; + const groups: UnifiedSearchSourceGroup[] = []; + for (const entry of sourceStatus) { + if (entry.contributors && entry.contributors.length > 0) { + for (const contributor of entry.contributors) { + const kind = + contributor.kind === "DOCPACK" ? "site_docs" : "repository_docs"; + appendSourceEntry(groups, kind, { + state: contributorState(contributor.state), + target: contributorTarget( + entry, + contributor.kind, + contributor.siteUrl, + ), + resultCount: contributor.resultCount, + }); + } + continue; + } + + const kind = sourceKind(entry); + appendSourceEntry(groups, kind, { + state: sourceState(entry), + target: sourceTarget(entry), + resultCount: entry.resultCount, + }); + } + return groups; +} + +function appendSourceEntry( + groups: UnifiedSearchSourceGroup[], + kind: UnifiedSearchSourceKind, + entry: UnifiedSearchSourceEntry, +): void { + const group = groups.find((candidate) => candidate.kind === kind); + if (group) group.entries.push(entry); + else groups.push({ kind, entries: [entry] }); +} + +function sourceKind( + entry: UnifiedSearchSourceStatusPayload, +): UnifiedSearchSourceKind { + if (entry.source.toLowerCase() === "code") return "code"; + return isSiteTarget(entry.targetLabel, entry) + ? "site_docs" + : "repository_docs"; +} + +function contributorTarget( + entry: UnifiedSearchSourceStatusPayload, + kind: "REPOSITORY_DOCS" | "DOCPACK", + siteUrl: string | undefined, +): string { + if (kind === "DOCPACK" && siteUrl) return siteUrl; + return sourceTarget(entry); +} + +function sourceTarget(entry: UnifiedSearchSourceStatusPayload): string { + return entry.servedTarget ?? entry.targetLabel; +} + +function sourceState( + entry: UnifiedSearchSourceStatusPayload, +): UnifiedSearchSourceReadiness { + const states = [entry.indexingStatus, entry.codeIndexState]; + if (states.some((state) => state === "INDEXING" || state === "PENDING")) { + return "waiting"; + } + if (states.some((state) => state === "FAILED" || state === "UNAVAILABLE")) { + return "unavailable"; + } + return "searched"; +} + +function contributorState( + state: "SEARCHED" | "READY" | "PENDING" | "UNAVAILABLE", +): UnifiedSearchSourceReadiness { + switch (state) { + case "SEARCHED": + return "searched"; + case "READY": + return "available_not_searched"; + case "PENDING": + return "waiting"; + case "UNAVAILABLE": + return "unavailable"; + } +} + +function projectTrustLimits( + snapshot: SnapshotFacts | undefined, + sources: UnifiedSearchSourceGroup[], + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): UnifiedSearchTrustLimit[] { + const limits: UnifiedSearchTrustLimit[] = []; + const seen = new Set(); + const add = (limit: UnifiedSearchTrustLimit): void => { + const key = JSON.stringify(limit); + if (!seen.has(key)) { + seen.add(key); + limits.push(limit); + } + }; + + for (const group of sources) { + for (const entry of group.entries) { + if (entry.state !== "searched") { + add({ + kind: "source", + source: group.kind, + state: entry.state, + target: entry.target, + }); + } + } + } + + for (const hit of snapshot?.results ?? []) { + if (!isHitPayload(hit)) continue; + if (hit.freshness === "STALE") { + add({ + kind: "stale", + target: hit.servedTarget ?? hit.target, + requestedTarget: hit.requestedTarget, + freshTarget: hit.freshTarget, + servedTarget: hit.servedTarget, + }); + } + } + + for (const entry of sourceStatus ?? []) { + const target = sourceTarget(entry); + const freshness = entry.targetResolution?.freshness; + if (entry.codeIndexState === "STALE" || freshness === "fallback_recent") { + add({ + kind: "stale", + target, + requestedTarget: entry.requestedTarget, + freshTarget: entry.freshTarget, + servedTarget: entry.servedTarget, + }); + } + if ( + entry.codeIndexState === "PROVISIONAL" || + freshness === "provisional" || + entry.contributors?.some( + (contributor) => contributor.freshness === "PROVISIONAL", + ) + ) { + add({ kind: "provisional", target }); + } + const kind = sourceKind(entry); + addCoverage(add, kind, target, entry.coverage); + for (const contributor of entry.contributors ?? []) { + const contributorTargetValue = contributorTarget( + entry, + contributor.kind, + contributor.siteUrl, + ); + if (contributor.freshness === "STALE") { + add({ kind: "stale", target: contributorTargetValue }); + } + addCoverage( + add, + contributor.kind === "DOCPACK" ? "site_docs" : "repository_docs", + contributorTargetValue, + contributor.coverage, + ); + } + addConstraints(add, entry); + } + + if (snapshot?.evidenceNotice !== undefined) { + add({ kind: "mutable_evidence" }); + } + return limits; +} + +function addCoverage( + add: (limit: UnifiedSearchTrustLimit) => void, + source: UnifiedSearchSourceKind, + target: string, + coverage: + | { + coverageState: string; + pagesCrawled?: number; + frontierRemaining?: number | null; + estimatedTotalPages?: number; + } + | undefined, +): void { + if (!coverage) return; + if ( + coverage.coverageState !== "PARTIAL" && + coverage.coverageState !== "CAPPED" + ) { + return; + } + add({ + kind: "coverage", + source, + state: coverage.coverageState.toLowerCase() as "partial" | "capped", + target, + pagesCrawled: coverage.pagesCrawled, + frontierRemaining: + typeof coverage.frontierRemaining === "number" + ? coverage.frontierRemaining + : undefined, + estimatedTotalPages: coverage.estimatedTotalPages, + }); +} + +function addConstraints( + add: (limit: UnifiedSearchTrustLimit) => void, + entry: UnifiedSearchSourceStatusPayload, +): void { + const target = entry.targetLabel; + const constraints: Array< + [UnifiedSearchConstraintKind, string[] | undefined] + > = [ + ["ignored_filter", entry.ignoredFilters], + ["incompatible_filter", entry.incompatibleFilters], + ["ignored_query_feature", entry.ignoredQueryFeatures], + ["incompatible_query_feature", entry.incompatibleQueryFeatures], + ]; + for (const [constraint, values] of constraints) { + if (values && values.length > 0) { + add({ kind: "constraint", constraint, source: target, values }); + } + } +} + +function projectWarnings( + query: UnifiedSearchQueryEcho | undefined, + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): UnifiedSearchWarning[] { + const warnings: UnifiedSearchWarning[] = []; + for (const message of query?.warnings ?? []) { + warnings.push({ kind: "query", message }); + } + for (const entry of sourceStatus ?? []) { + const source = entry.targetLabel; + const constraints: Array< + [UnifiedSearchConstraintKind, string[] | undefined] + > = [ + ["ignored_filter", entry.ignoredFilters], + ["incompatible_filter", entry.incompatibleFilters], + ["ignored_query_feature", entry.ignoredQueryFeatures], + ["incompatible_query_feature", entry.incompatibleQueryFeatures], + ]; + for (const [kind, values] of constraints) { + if (values && values.length > 0) warnings.push({ kind, source, values }); + } + } + return warnings; +} + +function projectAlternatives( + progress: UnifiedSearchProgressPayload | undefined, + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): UnifiedSearchAlternativeFacts[] { + const candidates: CandidateSet[] = []; + for (const target of progress?.targets ?? []) { + candidates.push({ + target: target.requested, + versions: + target.targetResolution?.availableVersions ?? + target.availableVersions ?? + [], + refs: + target.targetResolution?.availableRefs ?? target.availableRefs ?? [], + suggestedRefs: + target.targetResolution?.suggestedRefs ?? target.suggestedRefs ?? [], + }); + } + for (const entry of sourceStatus ?? []) { + const resolution = entry.targetResolution; + if (!resolution) continue; + candidates.push({ + target: sourceTarget(entry), + versions: resolution.availableVersions, + refs: resolution.availableRefs, + suggestedRefs: resolution.suggestedRefs ?? [], + }); + } + return candidates + .filter( + (candidate) => + candidate.versions.length > 0 || + candidate.refs.length > 0 || + candidate.suggestedRefs.length > 0, + ) + .map((candidate) => ({ + target: candidate.target, + ...boundedAlternatives( + candidate.versions, + candidate.refs, + candidate.suggestedRefs, + ), + })); +} + +function boundedAlternatives( + versions: UnifiedSearchAlternative[], + refs: UnifiedSearchAlternative[], + suggestedRefs: UnifiedSearchAlternative[], +): Omit { + return { + versions: versions.slice(0, MAX_ALTERNATIVES), + versionsRemaining: Math.max(0, versions.length - MAX_ALTERNATIVES), + refs: refs.slice(0, MAX_ALTERNATIVES), + refsRemaining: Math.max(0, refs.length - MAX_ALTERNATIVES), + suggestedRefs: suggestedRefs.slice(0, MAX_ALTERNATIVES), + suggestedRefsRemaining: Math.max( + 0, + suggestedRefs.length - MAX_ALTERNATIVES, + ), + }; +} + +interface ActionInput { + payload: UnifiedSearchPresentationInput; + snapshot: SnapshotFacts | undefined; + progress: UnifiedSearchProgressPayload | undefined; + lifecycle: UnifiedSearchLifecycle; + availability: UnifiedSearchAvailability; + sources: UnifiedSearchSourceGroup[]; + trustLimits: UnifiedSearchTrustLimit[]; + alternatives: UnifiedSearchAlternativeFacts[]; +} + +function projectAction(input: ActionInput): UnifiedSearchAction { + if (input.lifecycle.kind === "active") { + const searchRef = extractSearchRef(input.payload); + return searchRef ? { kind: "poll", searchRef } : { kind: "none" }; + } + if ( + input.lifecycle.kind === "terminal" || + input.lifecycle.kind === "unknown" + ) { + return { kind: "new_search" }; + } + if (!input.snapshot || input.availability.kind !== "empty") { + return { kind: "none" }; + } + + const hasIndexing = hasIndexingTrustSignal(input.snapshot.sourceStatus); + if (hasIndexing) { + const alternative = firstAlternative(input.alternatives); + if (alternative) return alternative; + return { kind: "new_search" }; + } + if ( + input.trustLimits.some( + (limit) => + limit.kind === "source" || + limit.kind === "coverage" || + limit.kind === "mutable_evidence" || + limit.kind === "stale", + ) + ) { + return { kind: "none" }; + } + + if (isStandaloneSiteSearch(input.snapshot.sourceStatus)) { + return { + kind: "query_rewrite", + rewrite: "site_shorter_or_broader", + }; + } + const query = input.snapshot.query; + if (hasRestrictiveFilters(query)) { + return { kind: "query_rewrite", rewrite: "remove_filters" }; + } + if (!query?.sources?.includes("symbol")) { + return { kind: "query_rewrite", rewrite: "shorter_or_broader" }; + } + return { kind: "query_rewrite", rewrite: "shorter_or_broader" }; +} + +function firstAlternative( + alternatives: UnifiedSearchAlternativeFacts[], +): UnifiedSearchAction | undefined { + for (const alternative of alternatives) { + const version = alternative.versions[0]; + if (version) { + return { + kind: "indexed_alternative", + target: alternative.target, + category: "version", + value: version.version ?? version.ref, + }; + } + const ref = alternative.refs[0]; + if (ref) { + return { + kind: "indexed_alternative", + target: alternative.target, + category: "ref", + value: ref.ref, + }; + } + } + return undefined; +} + +function hasIndexingTrustSignal( + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): boolean { + return Boolean( + sourceStatus?.some( + (entry) => + entry.indexingStatus === "INDEXING" || + entry.codeIndexState === "INDEXING" || + entry.codeIndexState === "PROVISIONAL" || + entry.targetResolution?.freshness === "indexing" || + entry.targetResolution?.freshness === "provisional" || + entry.contributors?.some( + (contributor) => contributor.freshness === "PROVISIONAL", + ), + ), + ); +} + +function hasRestrictiveFilters( + query: UnifiedSearchQueryEcho | undefined, +): boolean { + const filters = query?.filters; + return Boolean( + filters?.kind || + filters?.category || + filters?.pathPrefix || + filters?.fileIntent || + filters?.publicOnly === true || + (query?.raw && + /(?:^|\s)(?:kind|category|path|lang|name|intent):/i.test(query.raw)), + ); +} + +function isStandaloneSiteSearch( + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): boolean { + return Boolean( + sourceStatus?.length && + sourceStatus.every((entry) => isSiteTarget(entry.targetLabel, entry)), + ); +} + +function isSiteTarget( + target: string, + entry: UnifiedSearchSourceStatusPayload, +): boolean { + return Boolean( + target.startsWith("site:") || + entry.targetResolution?.requested?.site || + entry.targetResolution?.resolvedRequested?.site || + entry.targetResolution?.served?.site, + ); +} + +function isHitPayload(value: unknown): value is { + target: string; + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; + freshness?: string; +} { + return Boolean(value && typeof value === "object" && "target" in value); +} From a0056256dc136063b06b7837088b0d09fa564040 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:17:22 +0300 Subject: [PATCH 03/46] fix: complete unified search action facts Retain completed mutable-evidence continuations, contributor source identities, and the ordered applicable rewrite pivots in the shared presentation projection. Keep the model data-only and preserve conservative suppression for limited evidence. --- .../unified-search-presentation.test.ts | 126 +++++++++++++++++- .../src/shared/unified-search-presentation.ts | 94 ++++++++----- 2 files changed, 182 insertions(+), 38 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 07f13f2b..fe202911 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -176,6 +176,36 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.action).toEqual({ kind: "none" }); }); + it("continues completed mutable evidence through the exact initial reference", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + searchRef: "search-ref-initial", + evidenceNotice: "opaque notice", + }), + ); + + expect(presentation.action).toEqual({ + kind: "status", + searchRef: "search-ref-initial", + }); + }); + + it("continues completed mutable evidence through the exact status reference", () => { + const presentation = projectUnifiedSearchPresentation( + statusCompleted( + statusResult({ + results: [hit], + evidenceNotice: "opaque notice", + }), + ), + ); + + expect(presentation.action).toEqual({ + kind: "status", + searchRef: "search-ref-1", + }); + }); + it("classifies an empty searched snapshot and eligible pivots", () => { const presentation = projectUnifiedSearchPresentation( completed({ results: [], sourceStatus: [source({ resultCount: 0 })] }), @@ -200,7 +230,7 @@ describe("projectUnifiedSearchPresentation", () => { ]); expect(presentation.action).toEqual({ kind: "query_rewrite", - rewrite: "shorter_or_broader", + rewrites: ["shorter_or_broader", "symbol", "code_grep"], }); }); @@ -333,19 +363,30 @@ describe("projectUnifiedSearchPresentation", () => { entries: [ { state: "searched", - target: "npm:express@5.1.0", + target: "https://github.com/expressjs/express", + contextTarget: "npm:express@5.1.0", resultCount: 1, + repositoryUrl: "https://github.com/expressjs/express", }, ], }, { kind: "site_docs", entries: [ - { state: "waiting", target: "npm:express@5.1.0", resultCount: 0 }, + { + state: "waiting", + target: "expressjs.com", + contextTarget: "npm:express@5.1.0", + resultCount: 0, + siteKey: "expressjs.com", + }, { state: "available_not_searched", target: "https://api.example.com/reference", + contextTarget: "npm:express@5.1.0", resultCount: 0, + siteKey: "api.example.com", + siteUrl: "https://api.example.com/reference", }, ], }, @@ -357,7 +398,7 @@ describe("projectUnifiedSearchPresentation", () => { kind: "source", state: "waiting", source: "site_docs", - target: "npm:express@5.1.0", + target: "expressjs.com", }, { kind: "source", @@ -368,6 +409,77 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); + it("retains repository and site contributor identities", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:express@5.1.0", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/expressjs/express", + commitSha: "0123456789abcdef", + }, + { + kind: "DOCPACK", + state: "SEARCHED", + freshness: "STALE", + resultCount: 1, + siteKey: "expressjs.com", + coverage: { coverageState: "PARTIAL", pagesCrawled: 120 }, + }, + ], + }), + ], + }), + ); + + expect(presentation.sources).toEqual([ + { + kind: "repository_docs", + entries: [ + { + state: "searched", + target: "https://github.com/expressjs/express", + contextTarget: "npm:express@5.1.0", + resultCount: 1, + repositoryUrl: "https://github.com/expressjs/express", + commitSha: "0123456789abcdef", + }, + ], + }, + { + kind: "site_docs", + entries: [ + { + state: "searched", + target: "expressjs.com", + contextTarget: "npm:express@5.1.0", + resultCount: 1, + siteKey: "expressjs.com", + }, + ], + }, + ]); + expect(presentation.trustLimits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "stale", + target: "expressjs.com", + }), + expect.objectContaining({ + kind: "coverage", + source: "site_docs", + target: "expressjs.com", + }), + ]), + ); + }); + it("keeps progress-only source status empty while projecting target readiness", () => { const presentation = projectUnifiedSearchPresentation( incomplete({ @@ -659,7 +771,7 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.action).toEqual({ kind: "query_rewrite", - rewrite: "site_shorter_or_broader", + rewrites: ["site_shorter_or_broader"], }); }); @@ -679,11 +791,11 @@ describe("projectUnifiedSearchPresentation", () => { expect(filtered.action).toEqual({ kind: "query_rewrite", - rewrite: "remove_filters", + rewrites: ["shorter_or_broader", "remove_filters", "symbol", "code_grep"], }); expect(symbol.action).toEqual({ kind: "query_rewrite", - rewrite: "shorter_or_broader", + rewrites: ["shorter_or_broader", "code_grep"], }); }); diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 270737fa..56121088 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -48,7 +48,12 @@ export type UnifiedSearchSourceReadiness = export interface UnifiedSearchSourceEntry { state: UnifiedSearchSourceReadiness; target: string; + contextTarget?: string; resultCount?: number; + repositoryUrl?: string; + commitSha?: string; + siteKey?: string; + siteUrl?: string; } export interface UnifiedSearchSourceGroup { @@ -126,6 +131,7 @@ export type UnifiedSearchWarning = export type UnifiedSearchAction = | { kind: "poll"; searchRef: string } + | { kind: "status"; searchRef: string } | { kind: "new_search" } | { kind: "indexed_alternative"; @@ -135,14 +141,17 @@ export type UnifiedSearchAction = } | { kind: "query_rewrite"; - rewrite: - | "shorter_or_broader" - | "remove_filters" - | "symbol" - | "site_shorter_or_broader"; + rewrites: UnifiedSearchRewriteKind[]; } | { kind: "none" }; +export type UnifiedSearchRewriteKind = + | "shorter_or_broader" + | "remove_filters" + | "symbol" + | "code_grep" + | "site_shorter_or_broader"; + export interface UnifiedSearchPresentation { availability: UnifiedSearchAvailability; lifecycle: UnifiedSearchLifecycle; @@ -347,13 +356,10 @@ function projectSources( for (const contributor of entry.contributors) { const kind = contributor.kind === "DOCPACK" ? "site_docs" : "repository_docs"; + const identity = contributorIdentity(entry, contributor); appendSourceEntry(groups, kind, { state: contributorState(contributor.state), - target: contributorTarget( - entry, - contributor.kind, - contributor.siteUrl, - ), + ...identity, resultCount: contributor.resultCount, }); } @@ -389,13 +395,35 @@ function sourceKind( : "repository_docs"; } -function contributorTarget( +function contributorIdentity( entry: UnifiedSearchSourceStatusPayload, - kind: "REPOSITORY_DOCS" | "DOCPACK", - siteUrl: string | undefined, -): string { - if (kind === "DOCPACK" && siteUrl) return siteUrl; - return sourceTarget(entry); + contributor: NonNullable< + UnifiedSearchSourceStatusPayload["contributors"] + >[number], +): Pick< + UnifiedSearchSourceEntry, + | "target" + | "contextTarget" + | "repositoryUrl" + | "commitSha" + | "siteKey" + | "siteUrl" +> { + const contextTarget = sourceTarget(entry); + const target = + contributor.kind === "REPOSITORY_DOCS" + ? (contributor.repositoryUrl ?? contextTarget) + : (contributor.siteUrl ?? contributor.siteKey ?? contextTarget); + return { + target, + ...(target !== contextTarget ? { contextTarget } : {}), + ...(contributor.repositoryUrl + ? { repositoryUrl: contributor.repositoryUrl } + : {}), + ...(contributor.commitSha ? { commitSha: contributor.commitSha } : {}), + ...(contributor.siteKey ? { siteKey: contributor.siteKey } : {}), + ...(contributor.siteUrl ? { siteUrl: contributor.siteUrl } : {}), + }; } function sourceTarget(entry: UnifiedSearchSourceStatusPayload): string { @@ -495,18 +523,14 @@ function projectTrustLimits( const kind = sourceKind(entry); addCoverage(add, kind, target, entry.coverage); for (const contributor of entry.contributors ?? []) { - const contributorTargetValue = contributorTarget( - entry, - contributor.kind, - contributor.siteUrl, - ); + const contributorTargetValue = contributorIdentity(entry, contributor); if (contributor.freshness === "STALE") { - add({ kind: "stale", target: contributorTargetValue }); + add({ kind: "stale", target: contributorTargetValue.target }); } addCoverage( add, contributor.kind === "DOCPACK" ? "site_docs" : "repository_docs", - contributorTargetValue, + contributorTargetValue.target, contributor.coverage, ); } @@ -683,6 +707,13 @@ function projectAction(input: ActionInput): UnifiedSearchAction { ) { return { kind: "new_search" }; } + if ( + input.lifecycle.kind === "completed" && + input.snapshot?.evidenceNotice !== undefined + ) { + const searchRef = extractSearchRef(input.payload); + if (searchRef) return { kind: "status", searchRef }; + } if (!input.snapshot || input.availability.kind !== "empty") { return { kind: "none" }; } @@ -708,17 +739,18 @@ function projectAction(input: ActionInput): UnifiedSearchAction { if (isStandaloneSiteSearch(input.snapshot.sourceStatus)) { return { kind: "query_rewrite", - rewrite: "site_shorter_or_broader", + rewrites: ["site_shorter_or_broader"], }; } const query = input.snapshot.query; - if (hasRestrictiveFilters(query)) { - return { kind: "query_rewrite", rewrite: "remove_filters" }; - } - if (!query?.sources?.includes("symbol")) { - return { kind: "query_rewrite", rewrite: "shorter_or_broader" }; - } - return { kind: "query_rewrite", rewrite: "shorter_or_broader" }; + const rewrites: UnifiedSearchRewriteKind[] = ["shorter_or_broader"]; + if (hasRestrictiveFilters(query)) rewrites.push("remove_filters"); + const symbolSource = query?.sources?.some( + (source) => source.toLowerCase() === "symbol", + ); + if (!symbolSource) rewrites.push("symbol"); + rewrites.push("code_grep"); + return { kind: "query_rewrite", rewrites }; } function firstAlternative( From 6150eb25d0012ded4f4f769980df117862a10c72 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:19:13 +0300 Subject: [PATCH 04/46] fix: retain progress target presentation facts Keep requested, fresh, served, and freshness identities for every progress target while excluding internal diagnostics and alternative arrays. Preserve completed mutable-evidence continuations and ordered applicable rewrite pivots in the shared model. --- .../unified-search-presentation.test.ts | 57 +++++++++++++++++++ .../src/shared/unified-search-presentation.ts | 20 +++++++ 2 files changed, 77 insertions(+) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index fe202911..eb2d5645 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -260,6 +260,7 @@ describe("projectUnifiedSearchPresentation", () => { elapsedMs: 200, requestedSources: ["code"], }); + expect(presentation.targets).toEqual([]); expect(presentation.action.kind).toBe("poll"); }); @@ -506,6 +507,12 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.sources).toEqual([]); expect(presentation.trustLimits).toEqual([]); + expect(presentation.targets).toEqual([ + { + requested: "npm:n8n@2.36.7", + freshness: "INDEXING", + }, + ]); expect(presentation.alternatives).toEqual([ { target: "npm:n8n@2.36.7", @@ -554,6 +561,56 @@ describe("projectUnifiedSearchPresentation", () => { refs: [{ ref: "main" }], }), ]); + expect(presentation.targets).toEqual([ + { requested: "npm:express latest" }, + { requested: "github:expressjs/express#main" }, + ]); + }); + + it("retains progress target identities without diagnostics or alternatives", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + targets: [ + { + requested: "npm:express latest", + resolvedRequested: "npm:express@5.2.1", + served: "npm:express@5.1.0", + freshness: "STALE", + indexingRef: "idx-hidden", + requestedRefKind: "OMITTED_VERSION", + targetResolution: { + freshness: "fallback_recent", + freshnessReason: "latest_version_indexing", + indexingRef: "idx-hidden", + availableVersions: [], + availableRefs: [], + }, + }, + ], + }, + }), + ); + + expect(presentation.targets).toEqual([ + { + requested: "npm:express latest", + fresh: "npm:express@5.2.1", + served: "npm:express@5.1.0", + freshness: "STALE", + }, + ]); + expect(JSON.stringify(presentation.targets)).not.toContain("indexingRef"); + expect(JSON.stringify(presentation.targets)).not.toContain( + "OMITTED_VERSION", + ); + expect(JSON.stringify(presentation.targets)).not.toContain( + "latest_version_indexing", + ); }); it("projects the supplied n8n active empty snapshot without raw diagnostics", () => { diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 56121088..add2d8a0 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -68,6 +68,13 @@ export interface UnifiedSearchProgressPresentation { requestedSources?: string[]; } +export interface UnifiedSearchTargetPresentation { + requested?: string; + fresh?: string; + served?: string; + freshness?: string; +} + export interface UnifiedSearchAlternative { version?: string; ref: string; @@ -159,6 +166,7 @@ export interface UnifiedSearchPresentation { query?: UnifiedSearchQueryEcho; searchRef?: string; progress?: UnifiedSearchProgressPresentation; + targets: UnifiedSearchTargetPresentation[]; hasMore: boolean; sources: UnifiedSearchSourceGroup[]; trustLimits: UnifiedSearchTrustLimit[]; @@ -209,6 +217,7 @@ export function projectUnifiedSearchPresentation( query: snapshot?.query ?? extractQuery(payload), searchRef: extractSearchRef(payload), progress: projectProgress(progress.progress), + targets: projectTargets(progress.progress), hasMore: snapshot?.hasMore ?? false, sources, trustLimits, @@ -291,6 +300,17 @@ function projectProgress( }; } +function projectTargets( + progress: UnifiedSearchProgressPayload | undefined, +): UnifiedSearchTargetPresentation[] { + return (progress?.targets ?? []).map((target) => ({ + ...(target.requested ? { requested: target.requested } : {}), + ...(target.resolvedRequested ? { fresh: target.resolvedRequested } : {}), + ...(target.served ? { served: target.served } : {}), + ...(target.freshness ? { freshness: target.freshness } : {}), + })); +} + function projectLifecycle( payload: UnifiedSearchPresentationInput, progress: UnifiedSearchProgressPayload | undefined, From 458e3ca3f3edc8895142a04ba883fb73168af911 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:21:14 +0300 Subject: [PATCH 05/46] test: align n8n source readiness fixture Include the disclosed pending code source in the n8n presentation fixture and assert grouped readiness for code and documentation contributors. --- .../unified-search-presentation.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index eb2d5645..b000a85e 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -619,6 +619,13 @@ describe("projectUnifiedSearchPresentation", () => { partialResults: false, results: [], sourceStatus: [ + source({ + source: "code", + targetLabel: "npm:n8n@2.36.7", + indexingStatus: "INDEXING", + codeIndexState: "PENDING", + resultCount: 0, + }), source({ source: "docs", targetLabel: "npm:n8n@2.36.7", @@ -664,6 +671,43 @@ describe("projectUnifiedSearchPresentation", () => { kind: "active", status: "INDEXING", }); + expect(presentation.sources).toEqual([ + { + kind: "code", + entries: [ + { + state: "waiting", + target: "npm:n8n@2.36.7", + resultCount: 0, + }, + ], + }, + { + kind: "site_docs", + entries: [ + { + state: "available_not_searched", + target: "https://n8n.io", + contextTarget: "npm:n8n@2.36.7", + resultCount: 0, + siteKey: "n8n.io", + siteUrl: "https://n8n.io", + }, + ], + }, + { + kind: "repository_docs", + entries: [ + { + state: "waiting", + target: "https://github.com/n8n-io/n8n", + contextTarget: "npm:n8n@2.36.7", + resultCount: 0, + repositoryUrl: "https://github.com/n8n-io/n8n", + }, + ], + }, + ]); expect(presentation.alternatives[0]?.versions).toHaveLength(3); expect(presentation.alternatives[0]?.versionsRemaining).toBe(1); expect(presentation.alternatives[0]?.refs).toEqual([ From ce5b2019128165ea811890d38393d76156c343ee Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:39:14 +0300 Subject: [PATCH 06/46] feat: migrate MCP search text to presentation model Render search and search-status text from the shared lifecycle, availability, trust, source, alternative, and action projection. Keep hit anatomy and the CLI helper exports while removing duplicated MCP lifecycle prose. --- .../shared/unified-search-status-text.test.ts | 334 ++-- .../src/shared/unified-search-status-text.ts | 144 +- .../src/shared/unified-search-text.test.ts | 1656 +++++------------ .../mcp/src/shared/unified-search-text.ts | 497 ++++- 4 files changed, 1047 insertions(+), 1584 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 972dbb17..1571b1c0 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -1,230 +1,190 @@ import { describe, expect, it } from "bun:test"; import type { + UnifiedSearchHitPayload, UnifiedSearchStatusCompletedPayload, UnifiedSearchStatusIncompletePayload, + UnifiedSearchStatusResultPayload, } from "./unified-search-response.js"; import { renderUnifiedSearchStatusText } from "./unified-search-status-text.js"; -describe("renderUnifiedSearchStatusText", () => { - it("renders stored documentation contributors and the evidence notice once", () => { - const notice = - "Results reflect disclosed snapshots; pending work may change hits and ordering."; - const payload: UnifiedSearchStatusCompletedPayload = { - completed: true, - searchRef: "search-ref-docs", - result: { - partialResults: false, - hasMore: false, - results: [], - evidenceNotice: notice, - sourceStatus: [ - { - source: "docs", - targetLabel: "site:docs.example.com", - contributors: [ - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 0, - siteKey: "5555555555555555", - siteUrl: "https://docs.example.com/reference/", - coverage: { - coverageState: "PARTIAL", - pagesCrawled: 120, - frontierRemaining: null, - artifactOverflowPageCount: 0, - note: "Indexing is still in progress.", - }, - }, - ], - }, - ], - }, - }; +function hit(): UnifiedSearchHitPayload { + return { + type: "documentation_page", + target: "npm:express@5.2.1", + title: "Routing", + locator: { pageId: "express/routing" }, + }; +} + +function result( + overrides: Partial = {}, +): UnifiedSearchStatusResultPayload { + return { + query: { raw: "router" }, + partialResults: false, + hasMore: false, + results: [], + ...overrides, + }; +} + +function active( + overrides: Partial = {}, +): UnifiedSearchStatusIncompletePayload { + return { + completed: false, + searchRef: "search-ref-status", + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 100, + }, + ...overrides, + }; +} + +function firstLine(text: string): string { + return text.split("\n")[0] ?? ""; +} +describe("renderUnifiedSearchStatusText", () => { + it("uses the same outcome and exact Next action as initial search", () => { + const payload: UnifiedSearchStatusIncompletePayload = active({ + result: result({ results: [hit()] }), + }); const text = renderUnifiedSearchStatusText(payload); + expect(firstLine(text)).toBe( + "Indexing continues - 1 interim result returned", + ); + expect(text).toContain("[1] express/routing npm:express docs"); + expect(text).toContain("Do not repeat search.\nNext:"); expect(text).toContain( - "site docs.example.com/reference - searched; published snapshot is partial: 120 pages included", + 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', ); - expect(text).toContain("documentation sources:"); - expect(text).not.toContain("hits on this page"); - expect(text).not.toContain("documentation corpora"); - expect(text).not.toContain("Indexing is still in progress"); - expect(text).toContain("No hits in the searched evidence on this page."); - expect(text).toContain("Do not repeat immediately."); - expect(text.match(new RegExp(notice, "g"))).toHaveLength(1); - expect(text).not.toContain("next: call search_status"); + expect(text).not.toContain("search_status |"); + expect(text).not.toContain("searchRef="); }); - it("does not add an empty source-details separator", () => { - const payload: UnifiedSearchStatusCompletedPayload = { - completed: true, - searchRef: "search-ref-healthy", - result: { - partialResults: false, - hasMore: false, - results: [], - sourceStatus: [ - { - source: "docs", - targetLabel: "site:docs.example.com", - contributors: [], - }, - ], - }, - }; - - const text = renderUnifiedSearchStatusText(payload); - - expect(text).toContain("\n\nNo hits for docs on site:docs.example.com."); - expect(text).not.toContain("\n\n\n"); + it("keeps status and initial rendering aligned for equivalent partial evidence", () => { + const statusText = renderUnifiedSearchStatusText( + active({ + result: result({ partialResults: true, results: [hit()] }), + }), + ); + expect(firstLine(statusText)).toBe( + "Indexing continues - 1 partial result returned", + ); }); - it("does not overstate empty stored evidence when a source was not searched", () => { - const payload: UnifiedSearchStatusCompletedPayload = { - completed: true, - searchRef: "search-ref-unsearched", - result: { - partialResults: false, - hasMore: false, - results: [], - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "UNAVAILABLE", - resultCount: 0, - siteKey: "34150829eb8a7c57", - }, - ], - }, - ], - }, - }; - + it("distinguishes status snapshots with partialResults true", () => { + const payload = active({ + result: result({ partialResults: true, results: [hit()] }), + }); const text = renderUnifiedSearchStatusText(payload); + expect(firstLine(text)).toContain("1 partial result returned"); + expect(text).not.toContain("1 interim result"); + }); - expect(text).toContain("No hits in the searched evidence on this page."); - expect(text).not.toContain("No hits for docs"); - expect(text).toContain("Do not repeat this search unchanged."); - expect(text).toContain("next: shorten or broaden the query"); + it("renders progress-only status without inventing sources or a no-hits claim", () => { + const text = renderUnifiedSearchStatusText( + active({ + progress: { + status: "PENDING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 100, + targets: [{ requested: "npm:express", freshness: "PENDING" }], + }, + }), + ); + expect(firstLine(text)).toBe( + "Preparing npm:express - no result snapshot returned yet", + ); + expect(text).not.toContain("Waiting:"); + expect(text).not.toContain("No hits"); + expect(text).toContain("Ready: 0/1 targets"); + expect(text).toContain("Do not repeat search.\nNext:"); }); - it("does not leave a trailing separator after healthy stored results", () => { + it("renders a completed empty stored result with one applicable action", () => { const payload: UnifiedSearchStatusCompletedPayload = { completed: true, - searchRef: "search-ref-healthy", - result: { - partialResults: false, - hasMore: false, - results: [ - { - type: "documentation_page", - target: "npm:express@5.2.1", - title: "Routing", - locator: { - pageId: "express/routing", - sourceUrl: "https://expressjs.com/en/guide/routing.html", - }, - }, - ], + searchRef: "search-ref-empty", + result: result({ sourceStatus: [ { - source: "docs", + source: "code", targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - ], - }, - ], - }, - }; - - const text = renderUnifiedSearchStatusText(payload); - - expect(text.indexOf("searched:")).toBeLessThan(text.indexOf("[1]")); - expect(text.endsWith("\n")).toBe(false); - }); - - it("separates stored evidence metadata from the final hit", () => { - const payload: UnifiedSearchStatusCompletedPayload = { - completed: true, - searchRef: "search-ref-evidence", - result: { - partialResults: false, - hasMore: false, - evidenceNotice: "Results may change after pending work completes.", - results: [ - { - type: "documentation_page", - target: "npm:express@5.2.1", - title: "Routing", - locator: { pageId: "express/routing" }, + resultCount: 0, }, ], - }, + }), }; - const text = renderUnifiedSearchStatusText(payload); - - expect(text).toContain( - "\n\nevidence notice: Results may change after pending work completes.", - ); - expect(text.endsWith("\n")).toBe(false); + expect(firstLine(text)).toContain("No results returned"); + expect(text).toContain("Searched: code"); + expect(text).toContain("Do not repeat this search unchanged."); + expect(text).toContain("shorten or broaden query"); + expect(text).not.toContain("search-ref-empty"); }); - it("separates stored evidence metadata from empty-result guidance", () => { + it("continues completed mutable evidence through one status action", () => { const payload: UnifiedSearchStatusCompletedPayload = { completed: true, searchRef: "search-ref-evidence", - result: { - partialResults: false, - hasMore: false, - evidenceNotice: "Results may change after pending work completes.", - results: [], - }, + result: result({ + results: [hit()], + evidenceNotice: "opaque backend notice", + }), }; - const text = renderUnifiedSearchStatusText(payload); - + expect(firstLine(text)).toContain("1 result"); + expect(text).toContain("Evidence may change."); + expect(text).toContain("Do not repeat immediately.\nNext:"); expect(text).toContain( - "Do not repeat immediately.\n\nevidence notice: Results may change after pending work completes.", + 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); - expect(text.endsWith("\n")).toBe(false); + expect(text).not.toContain("opaque backend notice"); }); - it("separates incomplete next actions from returned hits", () => { - const payload: UnifiedSearchStatusIncompletePayload = { - completed: false, - searchRef: "search-ref-incomplete", - result: { - partialResults: false, - hasMore: false, - results: [ - { - type: "documentation_page", - target: "npm:express@5.2.1", - title: "Routing", - locator: { pageId: "express/routing" }, + it.each(["DEFERRED", "TIMEOUT", "FAILED"] as const)( + "does not poll a terminal stored status: %s", + (status) => { + const text = renderUnifiedSearchStatusText( + active({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, }, - ], - }, - }; - - const text = renderUnifiedSearchStatusText(payload); - - expect(text).toContain(" Routing\n\nDo not repeat search."); + }), + ); + expect(firstLine(text)).toStartWith(status); + expect(text).toContain( + "Do not call search_status again for this session.", + ); + expect(text).not.toContain("Next: search_status"); + }, + ); + + it("preserves unknown status without polling", () => { + const text = renderUnifiedSearchStatusText( + active({ + progress: { + status: "FUTURE_SESSION_STATE", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + }), + ); + expect(firstLine(text)).toBe( + "FUTURE_SESSION_STATE - no result snapshot returned", + ); + expect(text).toContain("Do not call search_status again for this session."); + expect(text).not.toContain("Next: search_status"); }); }); diff --git a/packages/mcp/src/shared/unified-search-status-text.ts b/packages/mcp/src/shared/unified-search-status-text.ts index 92429aad..9947f778 100644 --- a/packages/mcp/src/shared/unified-search-status-text.ts +++ b/packages/mcp/src/shared/unified-search-status-text.ts @@ -1,148 +1,20 @@ +import { projectUnifiedSearchPresentation } from "./unified-search-presentation.js"; import type { UnifiedSearchStatusCompletedPayload, UnifiedSearchStatusIncompletePayload, - UnifiedSearchStatusResultPayload, } from "./unified-search-response.js"; -import { - appendDocumentationSources, - appendEmptySearchGuidance, - appendEvidenceNotice, - appendIncompleteSearchNextAction, - appendSourceStatusNotes, - appendUnifiedSearchHits, - formatProgressTarget, - noHitsYetMessage, -} from "./unified-search-text.js"; - -const SEP = " | "; +import { renderUnifiedSearchPresentationText } from "./unified-search-text.js"; type StatusPayload = | UnifiedSearchStatusCompletedPayload | UnifiedSearchStatusIncompletePayload; export function renderUnifiedSearchStatusText(payload: StatusPayload): string { - const lines: string[] = []; - lines.push(buildHeader(payload)); - - if (!payload.completed && payload.progress) { - lines.push(formatProgress(payload.progress)); - if (payload.progress.targets?.length) { - lines.push("progress targets:"); - for (const target of payload.progress.targets) { - lines.push(` - ${formatProgressTarget(target)}`); - } - } - } - - const incompleteWarnings = !payload.completed - ? Array.from( - new Set([ - ...(payload.warnings ?? []), - ...(payload.result?.warnings ?? []), - ]), - ) - : []; - if (incompleteWarnings.length > 0) { - lines.push("warnings:"); - for (const warning of incompleteWarnings) lines.push(` - ${warning}`); - } - + const presentation = projectUnifiedSearchPresentation(payload); const result = payload.result; - if (result) { - appendResult( - lines, - result, - payload.completed, - payload.completed ? undefined : payload.progress, - payload.completed ? result.warnings : undefined, - ); - } - - const trailer: string[] = []; - if (result?.hasMore) { - const nextOffsetHint = - typeof result.nextOffset === "number" - ? ` Pass offset=${result.nextOffset} for the next page or limit=N to widen.` - : " Pass limit=N to widen."; - trailer.push(`More hits available.${nextOffsetHint}`); - } - if (result?.results.length) { - appendSourceStatusNotes(trailer, result.sourceStatus); - } - if (result) appendEvidenceNotice(trailer, result.evidenceNotice); - if (!payload.completed) { - appendIncompleteSearchNextAction( - trailer, - payload.progress?.status, - payload.searchRef, - ); - } - if (trailer.length > 0) { - if ( - (result?.results.length || result?.hasMore || result?.evidenceNotice) && - lines[lines.length - 1] !== "" - ) { - lines.push(""); - } - lines.push(...trailer); - } - - return lines.join("\n"); -} - -function buildHeader(payload: StatusPayload): string { - const state = payload.completed - ? "complete" - : (payload.progress?.status.toLowerCase() ?? "incomplete"); - const parts = [`search_status${SEP}${state}`]; - if (payload.searchRef) parts.push(`searchRef=${payload.searchRef}`); - return parts.join(SEP); -} - -function appendResult( - lines: string[], - result: UnifiedSearchStatusResultPayload, - completed: boolean, - progress: UnifiedSearchStatusIncompletePayload["progress"] | undefined, - warnings: string[] | undefined, -): void { - lines.push(""); - if (warnings && warnings.length > 0) { - lines.push("warnings:"); - for (const warning of warnings) lines.push(` - ${warning}`); - lines.push(""); - } - if (result.results.length === 0) { - if (completed) { - const sourceDetailsStart = lines.length; - appendSourceStatusNotes(lines, result.sourceStatus); - appendDocumentationSources(lines, result.sourceStatus, result.results); - if (lines.length > sourceDetailsStart) lines.push(""); - appendEmptySearchGuidance(lines, { - query: result.query, - showQuery: true, - sourceStatus: result.sourceStatus, - evidenceNotice: result.evidenceNotice, - }); - } else { - const sourceDetailsStart = lines.length; - appendSourceStatusNotes(lines, result.sourceStatus); - appendDocumentationSources(lines, result.sourceStatus, result.results); - if (lines.length > sourceDetailsStart) lines.push(""); - lines.push(noHitsYetMessage(progress)); - } - } else { - appendDocumentationSources(lines, result.sourceStatus, result.results); - if (lines[lines.length - 1] !== "") lines.push(""); - appendUnifiedSearchHits(lines, result.results); - } -} - -function formatProgress(progress: { - status: string; - targetsReady: number; - targetsTotal: number; - elapsedMs: number; -}): string { - return `progress: ${progress.status}, ${progress.targetsReady}/${progress.targetsTotal} targets ready, ${progress.elapsedMs}ms elapsed`; + return renderUnifiedSearchPresentationText(presentation, { + results: result?.results ?? [], + hasMore: result?.hasMore ?? false, + nextOffset: result?.nextOffset, + }); } diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 750dd7ad..0ef4b023 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -4,6 +4,7 @@ import type { UnifiedSearchErrorPayload, UnifiedSearchHitPayload, UnifiedSearchIncompletePayload, + UnifiedSearchSourceStatusPayload, } from "./unified-search-response.js"; import { renderUnifiedSearchError, @@ -49,24 +50,6 @@ function docsHit( }; } -function symbolHit(): UnifiedSearchHitPayload { - return { - type: "repository_symbol", - target: "continuedev/continue@v0.9.42", - title: "diffLines", - summary: "Myers diff core; line-level with O(ND) complexity.", - locator: { - filePath: "core/diff/myers.ts", - startLine: 48, - endLine: 112, - qualifiedPath: "core.diff.myers.diffLines", - kind: "function", - category: "callable", - language: "typescript", - }, - }; -} - function completed( results: UnifiedSearchHitPayload[], overrides: Partial = {}, @@ -81,8 +64,126 @@ function completed( }; } +function incomplete( + overrides: Partial = {}, +): UnifiedSearchIncompletePayload { + return { + query: { raw: "router" }, + completed: false, + hasMore: false, + results: [], + searchRef: "ref_abc-123", + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 8200, + }, + ...overrides, + }; +} + +function source( + overrides: Partial = {}, +): UnifiedSearchSourceStatusPayload { + return { + source: "code", + targetLabel: "npm:express@4.18.2", + ...overrides, + }; +} + +function n8nActiveEmpty(): UnifiedSearchIncompletePayload { + return incomplete({ + query: { raw: "human review approval node output" }, + searchRef: "fabUr1S3MEVeSgD93pMoSQ", + partialResults: false, + results: [], + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 8200, + targets: [ + { + requested: "npm:n8n", + resolvedRequested: "npm:n8n@2.36.7", + freshness: "INDEXING", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + { version: "2.22.6", ref: "v2.22.6" }, + { version: "2.21.7", ref: "v2.21.7" }, + ], + availableRefs: [{ ref: "HEAD" }, { ref: "master" }], + }, + ], + }, + sourceStatus: [ + source({ + source: "code", + targetLabel: "npm:n8n@2.36.7", + indexingStatus: "INDEXING", + codeIndexState: "PENDING", + resultCount: 0, + }), + source({ + source: "docs", + targetLabel: "npm:n8n@2.36.7", + targetResolution: { + freshness: "indexing", + freshnessReason: "latest_version_indexing", + indexingRef: "indexing-ref-hidden", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + { version: "2.22.6", ref: "v2.22.6" }, + ], + availableRefs: [{ ref: "HEAD" }, { ref: "master" }], + }, + contributors: [ + { + kind: "DOCPACK", + state: "READY", + resultCount: 0, + siteKey: "n8n.io", + siteUrl: "https://n8n.io", + coverage: { coverageState: "CAPPED", pagesCrawled: 1480 }, + }, + { + kind: "REPOSITORY_DOCS", + state: "PENDING", + resultCount: 0, + repositoryUrl: "https://github.com/n8n-io/n8n", + }, + ], + }), + ], + evidenceNotice: "Opaque evidence notice.", + }); +} + +function firstLine(text: string): string { + return text.split("\n")[0] ?? ""; +} + describe("renderUnifiedSearchSuccess", () => { - it("renders an empty completed envelope with bounded anti-retry guidance", () => { + it("starts completed hits with the outcome and preserves hit anatomy", () => { + const text = renderUnifiedSearchSuccess(completed([codeHit()])); + + expect(firstLine(text)).toContain("1 result"); + expect(firstLine(text)).not.toContain("search |"); + expect(text).toContain("[1] cline/cline@v3.4.2 code"); + expect(text).toContain( + ' code_read target="npm:cline@v3.4.2" path="src/integrations/diff/strategies/multi-search-replace.ts" start_line=142 end_line=156 function', + ); + expect(text).toContain(" applyEdit"); + expect(text).not.toContain("searchRef="); + }); + + it("renders completed empty evidence once and uses model pivots", () => { const text = renderUnifiedSearchSuccess( completed([], { query: { @@ -90,146 +191,265 @@ describe("renderUnifiedSearchSuccess", () => { filters: { kind: "function" }, }, sourceStatus: [ - { + source({ source: "code", targetLabel: "npm:express@5.2.1", - requestedTarget: "npm:express latest", - servedTarget: "npm:express@5.2.1", codeIndexState: "CURRENT", resultCount: 0, - }, + }), ], }), ); - expect(text).toContain("0 hits"); - expect(text).toContain('query="diff myers"'); - expect(text).toContain( - "No hits for code on npm:express@5.2.1 (requested npm:express latest; current).", - ); + + expect(firstLine(text)).toContain("No results returned"); + expect(text).toContain("Searched: code"); expect(text).toContain("Do not repeat this search unchanged."); - expect(text).toContain("shorten or broaden the query"); + expect(text).toContain("shorten or broaden query"); expect(text).toContain("remove restrictive filters"); expect(text).toContain('source="symbol"'); - expect(text).toContain("known literal or regex"); + expect(text).toContain("code_grep"); + expect(text).not.toContain('query="'); + expect(text.match(/Do not repeat this search unchanged\./g)).toHaveLength( + 1, + ); + }); + + it("renders the supplied n8n active empty snapshot with one concise readiness block", () => { + const text = renderUnifiedSearchSuccess(n8nActiveEmpty()); + const lines = text.split("\n"); + + expect(lines[0]).toBe("Indexing npm:n8n@2.36.7 - no results returned yet"); + expect(text).toContain("Ready: 0/1 targets"); + expect(text).toContain("Waiting: code, repository docs"); + expect(text).toContain( + "Available but not searched: n8n.io docs (1,480 pages; capped)", + ); + expect(text).toContain( + "Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2 more; refs HEAD, master", + ); + expect(text).toContain( + 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', + ); + expect(text).toContain("Do not repeat search.\nNext:"); + expect(text).not.toContain("indexingRef"); + expect(text).not.toContain("freshnessReason"); + expect(text).not.toContain("Opaque evidence notice"); + expect(text.match(/Indexing/g)).toHaveLength(1); + expect(text.match(/Ready:/g)).toHaveLength(1); + expect(text.match(/Next:/g)).toHaveLength(1); }); - it("directs completed indexing results to wait or indexed alternatives", () => { + it("does not invent source details for a true progress-only response", () => { const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "code", - targetLabel: "npm:express@5.2.1", - servedTarget: "npm:express@5.2.1", - indexingStatus: "INDEXING", - codeIndexState: "INDEXING", - resultCount: 0, - targetResolution: { - freshness: "indexing", - availableVersions: [{ version: "5.1.0", ref: "v5.1.0" }], - availableRefs: [], + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 100, + targets: [ + { + requested: "npm:n8n", + resolvedRequested: "npm:n8n@2.36.7", + freshness: "INDEXING", + availableVersions: [{ version: "2.26.9", ref: "v2.26.9" }], }, - }, - ], + ], + }, }), ); - expect(text).toContain("wait_timeout_ms"); - expect(text).toContain("queryable now"); - expect(text).not.toContain("shorten or broaden the query"); + expect(firstLine(text)).toBe( + "Indexing npm:n8n@2.36.7 - no result snapshot returned yet", + ); + expect(text).toContain("Ready: 0/1 targets"); + expect(text).not.toContain("Waiting:"); + expect(text).not.toContain("Available but not searched:"); + expect(text).not.toContain("n8n.io"); + expect(text).toContain("Indexed alternatives: versions 2.26.9"); + expect(text).toContain("Do not repeat search.\nNext:"); + }); + + it.each([ + ["PENDING", "Preparing"], + ["INDEXING", "Indexing"], + ["SEARCHING", "Searching"], + ] as const)( + "keeps %s lifecycle distinct without a snapshot", + (status, label) => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 20, + }, + }), + ); + expect(firstLine(text)).toStartWith(label); + expect(firstLine(text)).toContain("no result snapshot returned yet"); + expect(firstLine(text)).not.toContain("No results returned yet"); + }, + ); + + it.each([ + [false, "interim"], + [true, "partial"], + ] as const)( + "distinguishes atomic interim from %s results", + (partial, label) => { + const text = renderUnifiedSearchSuccess( + incomplete({ + partialResults: partial, + results: [codeHit()], + progress: { + status: "INDEXING", + targetsReady: 1, + targetsTotal: 1, + elapsedMs: 20, + }, + }), + ); + expect(firstLine(text)).toContain(`1 ${label} result`); + expect(firstLine(text)).not.toContain("final"); + }, + ); + + it.each(["DEFERRED", "TIMEOUT", "FAILED"] as const)( + "renders terminal %s exactly once and never polls", + (status) => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + }), + ); + expect(firstLine(text)).toStartWith(status); + expect(text).toContain( + "Do not call search_status again for this session.", + ); + expect(text).not.toContain("Next: search_status"); + }, + ); + + it("keeps an unknown lifecycle raw and conservative", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "FUTURE_SESSION_STATE", + targetsReady: 1, + targetsTotal: 2, + elapsedMs: 60_000, + }, + }), + ); + expect(firstLine(text)).toBe( + "FUTURE_SESSION_STATE - no result snapshot returned", + ); + expect(text).toContain("Do not call search_status again for this session."); + expect(text).not.toContain("Next: search_status"); + expect(text).not.toContain("indexing"); }); - it("renders provisional evidence as queryable while indexing continues", () => { + it("renders stale and provisional evidence as trust limits without raw diagnostics", () => { const text = renderUnifiedSearchSuccess( completed([], { sourceStatus: [ - { - source: "code", - targetLabel: "github:foo/bar#main", + source({ + targetLabel: "npm:express@5.2.1", + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", codeIndexState: "PROVISIONAL", targetResolution: { - requested: { - repoUrl: "https://github.com/foo/bar", - gitRef: "main", - }, - served: { - repoUrl: "https://github.com/foo/bar", - gitRef: "main", - commitSha: "abc123789def", - }, - freshness: "provisional", + freshness: "fallback_recent", freshnessReason: "exact_provisional", - indexingRef: "idx_123", + indexingRef: "idx-hidden", availableVersions: [], availableRefs: [], }, - }, + }), ], }), ); - - expect(text).toContain("provisional (still indexing)"); - expect(text).toContain("served=github:foo/bar#main@abc1237"); - expect(text).toContain("indexingRef=idx_123"); - expect(text).toContain( - "next: rerun with a larger wait_timeout_ms to wait for indexing.", - ); - expect(text).not.toContain("shorten or broaden the query"); + expect(text).toContain("Evidence:"); + expect(text).toContain("older snapshot"); + expect(text).toContain("provisional"); + expect(text).not.toContain("idx-hidden"); + expect(text).not.toContain("exact_provisional"); + expect(text).not.toContain("shorten or broaden query"); }); - it("treats target-resolution-only provisional evidence as still indexing", () => { + it("turns an evidence notice into one concise mutable-evidence action", () => { const text = renderUnifiedSearchSuccess( completed([], { - sourceStatus: [ - { - source: "code", - targetLabel: "github:foo/bar#main", - targetResolution: { - freshness: "provisional", - availableVersions: [], - availableRefs: [], - }, - }, - ], + searchRef: "search-ref-evidence", + evidenceNotice: "Opaque backend prose must not be copied.", }), ); - + expect(firstLine(text)).toBe("No results returned"); + expect(text).toContain("Evidence may change."); + expect(text).toContain("Do not repeat immediately.\nNext:"); expect(text).toContain( - "next: rerun with a larger wait_timeout_ms to wait for indexing.", + 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); - expect(text).not.toContain("shorten or broaden the query"); + expect(text).not.toContain("Opaque backend prose"); + expect(text).not.toContain("Do not repeat this search unchanged."); }); - it("does not suggest symbol search when already using the symbol source", () => { + it("continues completed mutable evidence with hits through the exact reference", () => { const text = renderUnifiedSearchSuccess( - completed([], { - query: { raw: "Router", sources: ["symbol"] }, + completed([codeHit()], { + searchRef: "search-ref-results", + evidenceNotice: "Opaque backend prose must not be copied.", }), ); - - expect(text).not.toContain('source="symbol"'); - expect(text).not.toContain("remove restrictive filters"); + expect(firstLine(text)).toContain("1 result"); + expect(text).toContain("Evidence may change."); + expect(text).toContain("Do not repeat immediately.\nNext:"); + expect(text).toContain( + 'Next: search_status search_ref="search-ref-results" wait_timeout_ms=20000', + ); }); - it("does not call explicit public_only=false restrictive", () => { + it("prints query and structured constraint warnings once below the outcome", () => { const text = renderUnifiedSearchSuccess( completed([], { - query: { raw: "Router", filters: { publicOnly: false } }, + query: { + raw: "router kind:function", + warnings: ["kind was ignored by the selected source"], + }, + warnings: ["duplicated promoted warning must not render"], + sourceStatus: [ + source({ + incompatibleQueryFeatures: ["kind"], + ignoredFilters: ["category"], + }), + ], }), ); - - expect(text).not.toContain("remove restrictive filters"); + expect(firstLine(text)).toContain("No results returned"); + expect(text).toContain("Warnings:"); + expect(text).toContain("kind was ignored by the selected source"); + expect(text).toContain("Incompatible query feature"); + expect(text).toContain("Ignored filter"); + expect(text).not.toContain("duplicated promoted warning"); + expect(text.match(/Warnings:/g)).toHaveLength(1); }); - it("keeps standalone docs site pivots within applicable sources", () => { + it("uses only the standalone-site query pivot", () => { const text = renderUnifiedSearchSuccess( completed([], { query: { raw: "middleware", sources: ["docs"] }, sourceStatus: [ - { + source({ source: "docs", targetLabel: "site:expressjs.com", - resultCount: 0, targetResolution: { requested: { site: "site:expressjs.com" }, served: { site: "site:expressjs.com" }, @@ -237,1156 +457,158 @@ describe("renderUnifiedSearchSuccess", () => { availableVersions: [], availableRefs: [], }, - }, + }), ], }), ); - - expect(text).not.toContain("code_grep"); + expect(text).toContain("shorten or broaden site query"); expect(text).not.toContain('source="symbol"'); - expect(text).toContain("next: shorten or broaden the query."); + expect(text).not.toContain("code_grep"); }); - it("prefers a failed lifecycle state over a healthy sibling", () => { + it("prefers an indexed alternative while indexing instead of query rewrites", () => { const text = renderUnifiedSearchSuccess( completed([], { - warnings: ["Source 'code' for npm:express@5.2.1: status FAILED"], sourceStatus: [ - { - source: "code", + source({ targetLabel: "npm:express@5.2.1", - servedTarget: "npm:express@5.2.1", - indexingStatus: "FAILED", - codeIndexState: "CURRENT", - resultCount: 0, - }, + indexingStatus: "INDEXING", + targetResolution: { + freshness: "indexing", + availableVersions: [{ version: "5.1.0", ref: "v5.1.0" }], + availableRefs: [], + }, + }), ], }), ); - - expect(text).toContain("No hits for code on npm:express@5.2.1 (failed)."); - expect(text).not.toContain( - "No hits for code on npm:express@5.2.1 (current).", - ); + expect(text).toContain("Next: search indexed version 5.1.0"); + expect(text).not.toContain("shorten or broaden query"); + expect(text).not.toContain("code_grep"); }); - it("prefers a stale lifecycle state over a healthy sibling", () => { - const text = renderUnifiedSearchSuccess( + it("only includes applicable filter and symbol/code_grep pivots", () => { + const filtered = renderUnifiedSearchSuccess( completed([], { - sourceStatus: [ - { - source: "code", - targetLabel: "npm:express@5.2.1", - servedTarget: "npm:express@5.2.1", - indexingStatus: "STALE", - codeIndexState: "CURRENT", - resultCount: 0, - }, - ], + query: { raw: "router", filters: { kind: "function" } }, }), ); + expect(filtered).toContain("remove restrictive filters"); + expect(filtered).toContain('source="symbol"'); + expect(filtered).toContain("code_grep"); - expect(text).toContain( - "No hits for code on npm:express@5.2.1 (previous-snapshot).", - ); - expect(text).not.toContain( - "No hits for code on npm:express@5.2.1 (current).", + const symbol = renderUnifiedSearchSuccess( + completed([], { query: { raw: "Router", sources: ["symbol"] } }), ); + expect(symbol).not.toContain('source="symbol"'); + expect(symbol).toContain("code_grep"); }); - it("renders a single code hit with locator, title, and summary", () => { - const text = renderUnifiedSearchSuccess(completed([codeHit()])); - expect(text).toContain("[1] cline/cline@v3.4.2 code"); - expect(text).not.toContain("0.87"); - expect(text).toContain( - ' code_read target="npm:cline@v3.4.2" path="src/integrations/diff/strategies/multi-search-replace.ts" start_line=142 end_line=156 function', + it("bounds alternatives and preserves pagination and result ordering", () => { + const text = renderUnifiedSearchSuccess( + completed([codeHit(), docsHit()], { + hasMore: true, + nextOffset: 10, + sourceStatus: [ + source({ + targetLabel: "npm:express", + targetResolution: { + availableVersions: [ + { version: "5.2.1", ref: "v5.2.1" }, + { version: "5.2.0", ref: "v5.2.0" }, + { version: "5.1.0", ref: "v5.1.0" }, + { version: "5.0.0", ref: "v5.0.0" }, + ], + availableRefs: [ + { ref: "HEAD" }, + { ref: "main" }, + { ref: "next" }, + { ref: "dev" }, + ], + }, + }), + ], + }), ); - expect(text).toContain(" applyEdit"); + expect(text).toContain("[1] cline/cline@v3.4.2"); + expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); expect(text).toContain( - " Search/replace block parser with fuzzy fallback when exact match fails.", + "Indexed alternatives: versions 5.2.1, 5.2.0, 5.1.0 +1 more; refs HEAD, main, next +1 more", ); + expect(text).toContain("More hits available. Pass offset=10"); + expect(text).not.toContain("v5.0.0"); + expect(text).not.toContain("dev"); }); - it("uses pageId for documentation hits", () => { - const text = renderUnifiedSearchSuccess(completed([docsHit()])); - expect(text).toContain("[1] aider/edit-formats aider-AI/aider docs"); - expect(text).toContain(' docs_read page_id="aider/edit-formats"'); - expect(text).toContain(" Edit Formats"); - }); - - it("renders qualifiedPath alongside file location for symbol hits", () => { - const text = renderUnifiedSearchSuccess(completed([symbolHit()])); - expect(text).toContain("[1] continuedev/continue@v0.9.42 symbol"); - expect(text).toContain( - " follow-up unavailable: missing target core.diff.myers.diffLines | function", + it("shows capped searched coverage without repeating the trust limit", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:docs.example.com", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + freshness: "CURRENT", + resultCount: 0, + siteKey: "docs.example.com", + siteUrl: "https://docs.example.com", + coverage: { + coverageState: "PARTIAL", + pagesCrawled: 120, + }, + }, + ], + }), + ], + }), ); - expect(text).toContain(" diffLines"); + expect(text).toContain("Searched: site docs (120 pages; partial)"); + expect(text.match(/120 pages/g)).toHaveLength(1); }); - it("uses ASCII separators throughout (no multi-byte chars)", () => { + it("retains ASCII output and wraps long summaries", () => { const text = renderUnifiedSearchSuccess( - completed([codeHit(), symbolHit()]), + completed([ + codeHit({ + summary: + "This summary is intentionally long enough to force wrapping across multiple lines without using a non-ASCII separator.", + }), + ]), ); - // No common Unicode-Latin1 separator characters in the output. expect(text).not.toMatch(/[·…—–]/); + for (const line of text.split("\n")) { + if (!line.includes("code_read ")) + expect(line.length).toBeLessThanOrEqual(82); + } }); +}); - it("emits a truncation hint with offset when hasMore", () => { - const text = renderUnifiedSearchSuccess( - completed([codeHit()], { hasMore: true, nextOffset: 10 }), - ); - expect(text).toContain("More hits available. Pass offset=10"); +describe("renderUnifiedSearchError", () => { + it("renders an error without changing the envelope contract", () => { + const error: UnifiedSearchErrorPayload = { + error: "Target is indexing.", + code: "INDEXING", + retryable: true, + details: { indexingRef: "ref_xyz" }, + }; + const text = renderUnifiedSearchError(error); + expect(text).toContain("search | ERROR | code=INDEXING | retryable"); + expect(text).toContain("Target is indexing."); + expect(text).toContain("details:"); + expect(text).toContain(" indexingRef: ref_xyz"); }); - it("falls back to a plain widen hint when nextOffset is missing", () => { - const text = renderUnifiedSearchSuccess( - completed([codeHit()], { hasMore: true }), + it("omits retryable marker when not set", () => { + const error: UnifiedSearchErrorPayload = { + error: "Bad request.", + code: "INVALID_ARGUMENT", + }; + expect(renderUnifiedSearchError(error)).toBe( + "search | ERROR | code=INVALID_ARGUMENT\nBad request.", ); - expect(text).toContain("More hits available. Pass limit=N to widen."); - }); - - it("renders incomplete payloads with searchRef and progress hint", () => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "myers" }, - completed: false, - hasMore: false, - results: [codeHit()], - searchRef: "ref_abc-123", - progress: { - status: "INDEXING", - targetsReady: 1, - targetsTotal: 2, - elapsedMs: 8200, - }, - }; - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain("1 partial"); - expect(text).toContain("searchRef=ref_abc-123"); - expect(text).toContain("Indexing in progress.\nDo not repeat search."); - expect(text).toContain( - 'next: call search_status with search_ref="ref_abc-123" and wait_timeout_ms=20000.', - ); - expect(text).not.toContain("searchRef=ref_abc-123 to follow up"); - }); - - it.each(["FAILED", "TIMEOUT"] as const)( - "stops polling a terminal %s session", - (status) => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "myers" }, - completed: false, - hasMore: false, - results: [], - searchRef: `ref-${status.toLowerCase()}`, - progress: { - status, - targetsReady: 0, - targetsTotal: 1, - elapsedMs: 20_000, - }, - }; - - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain( - "Do not call search_status again for this session.", - ); - expect(text).toContain("next: rerun search"); - expect(text).not.toContain("next: call search_status"); - }, - ); - - it("renders terminal deferred evidence without polling or active-state claims", () => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "myers" }, - completed: false, - hasMore: false, - results: [codeHit()], - searchRef: "ref-deferred", - evidenceNotice: "Stored evidence remains usable.", - progress: { - status: "DEFERRED", - targetsReady: 1, - targetsTotal: 2, - elapsedMs: 600_000, - }, - }; - - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain("Search session deferred."); - expect(text).toContain( - "Background lifecycle work continues outside this search session.", - ); - expect(text).toContain("Use any disclosed evidence now."); - expect(text).toContain("Stored evidence remains usable."); - expect(text).toContain("next: rerun search later for a fresher snapshot."); - expect(text).not.toContain("next: call search_status"); - expect(text).not.toContain("No hits"); - expect(text).not.toContain("Indexing in progress"); - }); - - it("renders a deferred session without stored results as unknown evidence", () => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "myers" }, - completed: false, - hasMore: false, - results: [], - searchRef: "ref-deferred-empty", - progress: { - status: "DEFERRED", - targetsReady: 0, - targetsTotal: 1, - elapsedMs: 600_000, - }, - }; - - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain( - "No result snapshot is available for this deferred session.", - ); - expect(text).toContain("next: rerun search later for a fresher snapshot."); - expect(text).not.toContain("No hits"); - expect(text).not.toContain("Indexing in progress"); - expect(text).not.toContain("next: call search_status"); - }); - - it("preserves evidence for an unrecognized status without interpreting it", () => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "myers" }, - completed: false, - hasMore: false, - results: [codeHit()], - searchRef: "ref-future", - evidenceNotice: "Stored evidence remains usable.", - progress: { - status: "FUTURE_SESSION_STATE", - targetsReady: 1, - targetsTotal: 2, - elapsedMs: 600_000, - }, - }; - - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain("Search returned status FUTURE_SESSION_STATE."); - expect(text).toContain("This client does not recognize that status."); - expect(text).toContain("Use any disclosed evidence now."); - expect(text).toContain("Stored evidence remains usable."); - expect(text).toContain("next: rerun search later."); - expect(text).not.toContain("next: call search_status"); - expect(text).not.toContain("No hits"); - expect(text).not.toContain("Indexing in progress"); - expect(text).not.toContain("terminal"); - }); - - it("does not invent hits or indexing for an unrecognized status", () => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "myers" }, - completed: false, - hasMore: false, - results: [], - searchRef: "ref-future-empty", - progress: { - status: "FUTURE_SESSION_STATE", - targetsReady: 0, - targetsTotal: 1, - elapsedMs: 600_000, - }, - }; - - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain( - "No result snapshot is available for search status FUTURE_SESSION_STATE.", - ); - expect(text).toContain("next: rerun search later."); - expect(text).not.toContain("No hits"); - expect(text).not.toContain("indexing"); - expect(text).not.toContain("next: call search_status"); - expect(text).not.toContain("terminal"); - }); - - it("labels incomplete indexed alternatives as immediately queryable", () => { - const incomplete: UnifiedSearchIncompletePayload = { - query: { raw: "router" }, - completed: false, - hasMore: false, - results: [], - searchRef: "ref-indexing", - progress: { - status: "INDEXING", - targetsReady: 0, - targetsTotal: 1, - elapsedMs: 100, - targets: [ - { - requested: "npm:express latest", - availableVersions: [{ version: "4.18.2", ref: "v4.18.2" }], - availableRefs: [{ ref: "main" }], - }, - ], - }, - }; - - const text = renderUnifiedSearchSuccess(incomplete); - expect(text).toContain("0/1 targets"); - expect(text).toContain( - "queryable now: versions=4.18.2@v4.18.2 | refs=main", - ); - expect(text).not.toContain("allow_partial_results"); - }); - - it("wraps long summaries at the configured width", () => { - const longSummary = - "This summary is intentionally long enough to force the wrap logic to break it across multiple lines so the renderer's wrap behaviour is verified."; - const text = renderUnifiedSearchSuccess( - completed([codeHit({ summary: longSummary })]), - ); - const summaryLines = text - .split("\n") - .filter( - (line) => line.startsWith(" ") && line.includes("intentionally"), - ); - expect(summaryLines.length).toBeGreaterThanOrEqual(1); - for (const line of text.split("\n")) { - if (line.includes("code_read ")) continue; - // 4-space indent + content; allow some slack for the wrap target. - expect(line.length).toBeLessThanOrEqual(82); - } - }); - - it("renders source-status notes when the backend reports them", () => { - const text = renderUnifiedSearchSuccess( - completed([codeHit()], { - sourceStatus: [ - { - source: "code", - targetLabel: "npm/cline@v3.4.2", - ignoredFilters: ["fileIntent"], - note: "fileIntent unsupported on code source", - }, - ], - }), - ); - expect(text).toContain("source notes:"); - expect(text).toContain("- code (npm/cline@v3.4.2)"); - expect(text).toContain("ignored=fileIntent"); - }); - - it("renders structured site recovery guidance in backend order", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "site:example.com", - suggestedSiteTargets: [ - "site:example.com/docs", - "site:example.com/guide", - ], - suggestedSiteTargetsTruncated: true, - }, - ], - }), - ); - - expect(text).toContain( - "Suggested site targets: site:example.com/docs, site:example.com/guide", - ); - expect(text).toContain("Additional site targets were omitted."); - expect(text.indexOf("site:example.com/docs")).toBeLessThan( - text.indexOf("site:example.com/guide"), - ); - }); - - it("renders a warnings preamble when payload-level warnings are populated", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - warnings: [ - "Source 'docs' for npm:zod@4.3.6: incompatible query features [kind]", - ], - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:zod@4.3.6", - incompatibleQueryFeatures: ["kind"], - }, - ], - }), - ); - expect(text).toContain("warnings:"); - expect(text).toContain( - " - Source 'docs' for npm:zod@4.3.6: incompatible query features [kind]", - ); - // Source notes block still rendered for structured detail. - expect(text).toContain("source notes:"); - expect(text.indexOf("warnings:")).toBeLessThan( - text.indexOf("Do not repeat this search unchanged."), - ); - }); - - it("uses a compact headline when every requested source is empty", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "code", - targetLabel: "npm:zod@4.3.6", - resultCount: 0, - }, - { - source: "docs", - targetLabel: "npm:zod@4.3.6", - resultCount: 0, - }, - ], - }), - ); - - expect(text).toContain("No hits from any source (code, docs)."); - expect(text).not.toContain("No hits across"); - }); - - it("uses requestedRef when repo follow-up lacks served gitRef", () => { - const text = renderUnifiedSearchSuccess( - completed([ - codeHit({ - target: "https://github.com/expressjs/express default branch", - locator: { - repoUrl: "https://github.com/expressjs/express", - filePath: "lib/router/index.js", - requestedRef: "main", - }, - }), - ]), - ); - - expect(text).toContain( - 'code_read target="github:expressjs/express#main" path="lib/router/index.js"', - ); - expect(text).not.toContain("#HEAD"); - }); - - it("renders terminal source status compactly without raw target-resolution details", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - warnings: [ - "Source 'code' for githits-com/no-such-repo: Repository ref cannot be resolved (UNRESOLVABLE)", - ], - sourceStatus: [ - { - source: "code", - targetLabel: "githits-com/no-such-repo", - indexingStatus: "UNRESOLVABLE", - codeIndexState: "UNRESOLVABLE", - note: "Repository ref cannot be resolved", - targetResolution: { - requested: { - repoUrl: "https://github.com/githits-com/no-such-repo", - }, - resolvedRequested: { - repoUrl: "https://github.com/githits-com/no-such-repo", - gitRef: "HEAD", - }, - freshness: "indexing", - freshnessReason: "no_current_fallback", - availableVersions: [], - availableRefs: [], - }, - }, - ], - }), - ); - - expect(text).toContain( - "code (githits-com/no-such-repo) | Repository ref cannot be resolved (UNRESOLVABLE)", - ); - expect(text).not.toContain("state=indexing"); - }); - - it("omits the warnings preamble when no warnings are present", () => { - const text = renderUnifiedSearchSuccess(completed([codeHit()])); - expect(text).not.toContain("warnings:"); - expect(text).not.toContain("Do not repeat this search unchanged."); - expect(text).not.toContain('source="symbol"'); - }); - - it("separates multiple hits with a blank line", () => { - const text = renderUnifiedSearchSuccess( - completed([codeHit(), docsHit(), symbolHit()]), - ); - const hitHeaders = text.split("\n").filter((line) => /^\[\d\]/.test(line)); - expect(hitHeaders).toHaveLength(3); - expect(text).toContain("[1] cline/cline@v3.4.2"); - expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); - expect(text).toContain("[3] continuedev/continue@v0.9.42"); - }); - - it("lists healthy documentation references without repeating result metadata", () => { - const text = renderUnifiedSearchSuccess( - completed( - [ - docsHit({ - target: "npm:express@5.2.1", - locator: { - pageId: "express/routing", - sourceUrl: "https://wrong.example.net/inferred-from-hit", - sourceKind: "hosted", - }, - }), - ], - { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 4, - siteKey: "34150829eb8a7c57", - siteUrl: "https://expressjs.com/en/guide/", - coverage: { - coverageState: "COMPLETE", - pagesCrawled: 124, - frontierRemaining: 0, - artifactOverflowPageCount: 0, - }, - }, - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - ], - }, - { - source: "code", - targetLabel: "npm:express@5.2.1", - contributors: [], - }, - ], - }, - ), - ); - - const searchedLine = text - .split("\n") - .find((line) => line.startsWith("searched:")); - expect(searchedLine).toBe( - "searched: site expressjs.com/en/guide; repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567", - ); - expect(text).not.toContain("wrong.example.net"); - expect(text).toContain( - "repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567\n\n[1]", - ); - expect(text.indexOf("searched:")).toBeLessThan(text.indexOf("[1]")); - expect(text).not.toContain("documentation corpora"); - expect(text).not.toContain("hits on this page"); - expect(text).not.toContain("current"); - expect(text).not.toContain("124"); - }); - - it("labels searched provisional documentation as still indexing", () => { - const sourceStatus = [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "REPOSITORY_DOCS" as const, - state: "SEARCHED" as const, - freshness: "PROVISIONAL" as const, - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - ], - }, - ]; - const text = renderUnifiedSearchSuccess( - completed([docsHit()], { sourceStatus }), - ); - - expect(text).toContain( - "documentation sources:\n npm:express@5.2.1:\n - repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567 - searched provisional index; indexing continues", - ); - expect(text).toContain("[1]"); - - const emptyText = renderUnifiedSearchSuccess( - completed([], { sourceStatus }), - ); - expect(emptyText).toContain( - "next: rerun with a larger wait_timeout_ms to wait for indexing.", - ); - }); - - it("labels documentation sources only when multiple targets need disambiguation", () => { - const text = renderUnifiedSearchSuccess( - completed([docsHit()], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - ], - }, - { - source: "docs", - targetLabel: "npm:koa@3.0.1", - contributors: [ - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 0, - repositoryUrl: "https://github.com/koajs/koa", - commitSha: "abcdef0123456789abcdef0123456789abcdef01", - }, - ], - }, - ], - }), - ); - - expect(text).toContain( - "searched:\n npm:express@5.2.1: repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567\n npm:koa@3.0.1: repo https://github.com/koajs/koa @ abcdef0123456789abcdef0123456789abcdef01", - ); - expect(text.indexOf("searched:")).toBeLessThan(text.indexOf("[1]")); - }); - - it("renders a root docpack URL without a redundant trailing slash", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 0, - siteKey: "34150829eb8a7c57", - siteUrl: "https://expressjs.com/", - coverage: { coverageState: "COMPLETE" }, - }, - ], - }, - ], - }), - ); - - expect(text.split("\n").find((line) => line.startsWith("searched:"))).toBe( - "searched: site expressjs.com", - ); - }); - - it("keeps malformed optional site metadata from failing text output", () => { - for (const siteUrl of [ - "expressjs.com/en/guide", - "file:///opt/docs/index.html", - ]) { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "PENDING", - resultCount: 0, - siteKey: "34150829eb8a7c57", - siteUrl, - }, - ], - }, - ], - }), - ); - - expect(text).toContain( - "site documentation - not ready, so it was not searched", - ); - } - }); - - it("numbers docpack labels only when their displayed identities collide", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "PENDING", - resultCount: 0, - siteKey: "1111111111111111", - siteUrl: "https://docs.example.com/", - }, - { - kind: "DOCPACK", - state: "READY", - freshness: "CURRENT", - resultCount: 0, - siteKey: "2222222222222222", - siteUrl: "https://other.example.com", - coverage: { coverageState: "COMPLETE" }, - }, - { - kind: "DOCPACK", - state: "UNAVAILABLE", - resultCount: 0, - siteKey: "3333333333333333", - siteUrl: "https://docs.example.com", - }, - ], - }, - ], - }), - ); - - expect(text).toContain( - "site docs.example.com 1 - not ready, so it was not searched", - ); - expect(text).toContain( - "site other.example.com - available, but not searched for this response", - ); - expect(text).toContain( - "site docs.example.com 2 - unavailable and was not searched", - ); - }); - - it("retains the source target when another response target has no contributors", () => { - const text = renderUnifiedSearchSuccess( - completed( - [ - codeHit({ - target: "npm:express@5.2.1", - }), - ], - { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:koa@3.0.1", - contributors: [ - { - kind: "DOCPACK", - state: "PENDING", - resultCount: 0, - siteKey: "1111111111111111", - }, - ], - }, - { - source: "code", - targetLabel: "npm:express@5.2.1", - contributors: [], - }, - ], - }, - ), - ); - - expect(text).toContain( - "documentation sources:\n npm:koa@3.0.1:\n - site documentation - not ready, so it was not searched\n\n[1]", - ); - }); - - it("groups mixed source health by target without repeating section labels", () => { - const text = renderUnifiedSearchSuccess( - completed([docsHit()], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - ], - }, - { - source: "docs", - targetLabel: "npm:koa@3.0.1", - contributors: [ - { - kind: "DOCPACK", - state: "PENDING", - resultCount: 0, - siteKey: "1111111111111111", - }, - ], - }, - { - source: "docs", - targetLabel: "npm:react@19.1.1", - contributors: [ - { - kind: "DOCPACK", - state: "UNAVAILABLE", - resultCount: 0, - siteKey: "2222222222222222", - }, - ], - }, - ], - }), - ); - - expect(text).toContain( - "searched:\n npm:express@5.2.1: repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567\n\ndocumentation sources:\n npm:koa@3.0.1:\n - site documentation - not ready, so it was not searched\n npm:react@19.1.1:\n - site documentation - unavailable and was not searched", - ); - expect(text.match(/documentation sources:/g)).toHaveLength(1); - }); - - it("states searched contributors and missing coverage inside an exception block", () => { - const text = renderUnifiedSearchSuccess( - completed( - [ - docsHit({ - target: "npm:express@5.2.1", - locator: { - pageId: "express/routing", - sourceUrl: "https://expressjs.com/en/guide/routing.html", - }, - }), - ], - { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 0, - siteKey: "34150829eb8a7c57", - }, - ], - }, - ], - }, - ), - ); - - expect(text).toContain("documentation sources:"); - expect(text).toContain( - "repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567 - searched", - ); - expect(text).toContain( - "site documentation - searched; published coverage details unavailable", - ); - expect(text).not.toContain("searched: repo"); - }); - - it("explains capped page coverage without repeating the limit reason", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 0, - siteKey: "34150829eb8a7c57", - coverage: { - coverageState: "CAPPED", - coverageReason: "max_pages", - pagesCrawled: 500, - frontierRemaining: 24, - artifactOverflowPageCount: 0, - }, - }, - ], - }, - ], - }), - ); - - expect(text).toContain( - "site documentation - searched; published snapshot reached its page limit: 500 pages included, 24 discovered pages not included", - ); - expect(text).not.toContain("limited by max pages"); - expect(text).not.toContain("34150829eb8a7c57"); - }); - - it("explains documentation source exceptions without implying progress from coverage", () => { - const notice = - "Results reflect disclosed snapshots; pending work may change hits and ordering."; - const text = renderUnifiedSearchSuccess( - completed([docsHit()], { - searchRef: "search-ref-docs", - evidenceNotice: notice, - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.1.0", - contributors: [ - { - kind: "REPOSITORY_DOCS", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 1, - repositoryUrl: "https://github.com/expressjs/express", - commitSha: "0123456789abcdef0123456789abcdef01234567", - }, - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "STALE", - resultCount: 2, - siteKey: "34150829eb8a7c57", - siteUrl: "https://expressjs.com/en/guide", - coverage: { - coverageState: "CAPPED", - coverageReason: "artifact_size", - pagesCrawled: 480, - frontierRemaining: null, - artifactOverflowPageCount: 12, - estimatedTotalPages: 700, - note: "Indexing is still in progress.", - }, - }, - { - kind: "DOCPACK", - state: "READY", - freshness: "CURRENT", - resultCount: 0, - siteKey: "1111111111111111", - siteUrl: "https://koajs.com/docs", - coverage: { - coverageState: "COMPLETE", - pagesCrawled: 75, - frontierRemaining: 0, - artifactOverflowPageCount: 0, - }, - }, - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "STALE", - resultCount: 0, - siteKey: "2222222222222222", - siteUrl: "https://react.dev/reference", - coverage: { - coverageState: "NONE", - pagesCrawled: 69, - frontierRemaining: null, - artifactOverflowPageCount: 0, - note: "Coverage has not been computed.", - }, - }, - { - kind: "DOCPACK", - state: "PENDING", - resultCount: 0, - siteKey: "3333333333333333", - siteUrl: "https://docs.example.com/pending", - }, - { - kind: "DOCPACK", - state: "UNAVAILABLE", - resultCount: 0, - siteKey: "4444444444444444", - siteUrl: "https://docs.example.com/unavailable", - }, - { - kind: "DOCPACK", - state: "SEARCHED", - freshness: "CURRENT", - resultCount: 0, - siteKey: "5555555555555555", - siteUrl: "https://docs.example.com/capped", - coverage: { - coverageState: "CAPPED", - coverageReason: "trap_suspected", - pagesCrawled: 20, - frontierRemaining: null, - artifactOverflowPageCount: 0, - }, - }, - ], - }, - ], - }), - ); - - expect(text).toContain("documentation sources:"); - expect(text.indexOf("documentation sources:")).toBeLessThan( - text.indexOf("[1]"), - ); - expect(text).not.toContain("source notes:"); - expect(text).toContain( - "repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567 - searched", - ); - expect(text).toContain( - "site expressjs.com/en/guide - searched an older snapshot; published snapshot hit its size cap: 480 pages included, 12 pages omitted, about 700 estimated total", - ); - expect(text).toContain( - "site koajs.com/docs - available, but not searched for this response", - ); - expect(text).toContain( - "site react.dev/reference - searched an older snapshot; published coverage was not measured: 69 pages included", - ); - expect(text).not.toContain("Coverage has not been computed"); - expect(text).toContain( - "site docs.example.com/pending - not ready, so it was not searched", - ); - expect(text).toContain( - "site docs.example.com/unavailable - unavailable and was not searched", - ); - expect(text).toContain( - "site docs.example.com/capped - searched; published snapshot is capped: 20 pages included, limited by a suspected crawl trap", - ); - expect(text).not.toContain("hits on this page"); - expect(text).not.toContain("documentation corpora"); - expect(text).not.toContain("34150829eb8a7c57"); - expect(text).not.toContain("Indexing is still in progress"); - expect(text.match(new RegExp(notice, "g"))).toHaveLength(1); - expect(text).toContain( - 'next: call search_status with search_ref="search-ref-docs"', - ); - }); - - it("does not give query-pivot advice for empty evidence-bearing results", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - searchRef: "search-ref-docs", - evidenceNotice: "Pending work may change hits and ordering.", - }), - ); - - expect(text).toContain("No hits in the searched evidence on this page."); - expect(text).toContain("Do not repeat immediately."); - expect(text).not.toContain("Do not repeat this search unchanged."); - expect(text).not.toContain("shorten or broaden the query"); - expect(text).toContain( - 'next: call search_status with search_ref="search-ref-docs"', - ); - }); - - it("scopes empty claims to searched evidence when a source was not searched", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "READY", - freshness: "CURRENT", - resultCount: 0, - siteKey: "34150829eb8a7c57", - }, - ], - }, - ], - }), - ); - - expect(text).toContain("No hits in the searched evidence on this page."); - expect(text).not.toContain("No hits for docs"); - expect(text).toContain("Do not repeat this search unchanged."); - expect(text).toContain("next: shorten or broaden the query"); - }); - - it("keeps indexing guidance when documentation contributors were not searched", () => { - const text = renderUnifiedSearchSuccess( - completed([], { - sourceStatus: [ - { - source: "docs", - targetLabel: "npm:express@5.2.1", - contributors: [ - { - kind: "DOCPACK", - state: "READY", - freshness: "CURRENT", - resultCount: 0, - siteKey: "34150829eb8a7c57", - }, - ], - }, - { - source: "code", - targetLabel: "npm:express@5.2.1", - contributors: [], - indexingStatus: "INDEXING", - }, - ], - }), - ); - - expect(text).toContain("No hits in the searched evidence on this page."); - expect(text).toContain("Do not repeat this search unchanged."); - expect(text).toContain("indexState=INDEXING\n\ndocumentation sources:"); - expect(text).toContain( - "next: rerun with a larger wait_timeout_ms to wait for indexing.", - ); - expect(text).not.toContain("shorten or broaden the query"); - }); -}); - -describe("renderUnifiedSearchError", () => { - it("renders a basic error", () => { - const error: UnifiedSearchErrorPayload = { - error: "Target is indexing.", - code: "INDEXING", - retryable: true, - details: { indexingRef: "ref_xyz" }, - }; - const text = renderUnifiedSearchError(error); - expect(text).toContain("search | ERROR | code=INDEXING | retryable"); - expect(text).toContain("Target is indexing."); - expect(text).toContain("details:"); - expect(text).toContain(" indexingRef: ref_xyz"); - }); - - it("omits retryable marker when not set", () => { - const error: UnifiedSearchErrorPayload = { - error: "Bad request.", - code: "INVALID_ARGUMENT", - }; - const text = renderUnifiedSearchError(error); - expect(text).toBe("search | ERROR | code=INVALID_ARGUMENT\nBad request."); }); it("serialises object detail values via JSON", () => { @@ -1394,13 +616,9 @@ describe("renderUnifiedSearchError", () => { error: "Indexing.", code: "INDEXING", details: { - availableVersions: [ - { version: "4.21.0", ref: "v4.21.0" }, - { version: "4.20.0", ref: "v4.20.0" }, - ], + availableVersions: [{ version: "4.21.0", ref: "v4.21.0" }], }, }; - const text = renderUnifiedSearchError(error); - expect(text).toContain('"version":"4.21.0"'); + expect(renderUnifiedSearchError(error)).toContain('"version":"4.21.0"'); }); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 5bbb7280..17465131 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -24,6 +24,16 @@ import { formatTargetResolutionIdentity, type LeanTargetResolution, } from "./target-resolution.js"; +import { + projectUnifiedSearchPresentation, + type UnifiedSearchAction, + type UnifiedSearchAlternativeFacts, + type UnifiedSearchPresentation, + type UnifiedSearchSourceEntry, + type UnifiedSearchSourceGroup, + type UnifiedSearchTrustLimit, + type UnifiedSearchWarning, +} from "./unified-search-presentation.js"; import { isActiveUnifiedSearchSessionStatus, type UnifiedSearchCompletedPayload, @@ -46,43 +56,459 @@ type SearchSuccessPayload = export function renderUnifiedSearchSuccess( payload: SearchSuccessPayload, ): string { - const lines: string[] = []; - lines.push(buildHeader(payload)); - lines.push(""); + return renderUnifiedSearchPresentationText( + projectUnifiedSearchPresentation(payload), + payload, + ); +} - const completedEmpty = payload.completed && payload.results.length === 0; - if (completedEmpty) { - appendWarnings(lines, payload.warnings); - appendSourceStatusNotes(lines, payload.sourceStatus); - appendDocumentationSources(lines, payload.sourceStatus, payload.results); - if (lines[lines.length - 1] !== "") lines.push(""); - appendEmptySearchGuidance(lines, { - query: payload.query, - sourceStatus: payload.sourceStatus, - evidenceNotice: payload.evidenceNotice, - }); - } else if (payload.results.length === 0) { - appendDocumentationSources(lines, payload.sourceStatus, payload.results); +export interface UnifiedSearchTextResult { + results: UnifiedSearchHitPayload[]; + hasMore: boolean; + nextOffset?: number; +} + +/** Render the shared semantic projection while callers supply only result anatomy. */ +export function renderUnifiedSearchPresentationText( + presentation: UnifiedSearchPresentation, + result: UnifiedSearchTextResult, +): string { + const lines: string[] = [ + formatPresentationOutcome(presentation, result.results), + ]; + appendPresentationContext(lines, presentation); + + if (result.results.length > 0) { + lines.push(""); + appendUnifiedSearchHits(lines, result.results); + } + + if (result.hasMore) { if (lines[lines.length - 1] !== "") lines.push(""); + const nextOffsetHint = + typeof result.nextOffset === "number" + ? `More hits available. Pass offset=${result.nextOffset} or limit=N to widen.` + : "More hits available. Pass limit=N to widen."; + lines.push(nextOffsetHint); + } + + appendPresentationAlternatives(lines, presentation); + appendPresentationAction(lines, presentation); + return lines.join("\n"); +} + +function formatPresentationOutcome( + presentation: UnifiedSearchPresentation, + results: UnifiedSearchHitPayload[], +): string { + const target = presentationTarget(presentation, results); + const targetSuffix = target ? ` ${target}` : ""; + const count = presentation.availability.resultCount; + const countLabel = `${count} result${count === 1 ? "" : "s"}`; + + if (presentation.lifecycle.kind === "active") { + const label = capitalize(presentation.lifecycleHeadline ?? "active"); + if (presentation.availability.kind === "no_snapshot") { + return `${label}${targetSuffix} - no result snapshot returned yet`; + } + if (presentation.availability.kind === "empty") { + return `${label}${targetSuffix} - no results returned yet`; + } + const resultKind = + presentation.availability.kind === "partial" ? "partial" : "interim"; + return `${label} continues - ${countLabel.replace("result", `${resultKind} result`)} returned`; + } + + if (presentation.lifecycle.kind === "completed") { + return count > 0 + ? `${countLabel}${target ? ` from ${target}` : ""}` + : `No results returned${target ? ` from ${target}` : ""}`; + } + + const status = presentation.lifecycle.status ?? "UNKNOWN"; + if (count > 0) return `${status} - ${countLabel} returned`; + if (presentation.availability.kind === "no_snapshot") { + return `${status} - no result snapshot returned`; + } + return `${status} - no results returned`; +} + +function presentationTarget( + presentation: UnifiedSearchPresentation, + results: UnifiedSearchHitPayload[], +): string | undefined { + const hitTarget = results[0]?.target; + if (hitTarget) return hitTarget; + const target = presentation.targets[0]; + if (target) return target.served ?? target.fresh ?? target.requested; + return presentation.sources[0]?.entries[0]?.target; +} + +function appendPresentationContext( + lines: string[], + presentation: UnifiedSearchPresentation, +): void { + if (presentation.progress) { lines.push( - noHitsYetMessage("progress" in payload ? payload.progress : undefined), + `Ready: ${presentation.progress.targetsReady}/${presentation.progress.targetsTotal} targets`, ); - } else { - appendDocumentationSources(lines, payload.sourceStatus, payload.results); - if (lines[lines.length - 1] !== "") lines.push(""); - appendUnifiedSearchHits(lines, payload.results); } + appendPresentationTargetDivergence(lines, presentation); + appendPresentationSources( + lines, + presentation.sources, + presentation.trustLimits, + ); + appendPresentationTrust(lines, presentation.trustLimits); + appendPresentationWarnings(lines, presentation.warnings); +} + +function appendPresentationTargetDivergence( + lines: string[], + presentation: UnifiedSearchPresentation, +): void { + for (const target of presentation.targets) { + const identities = [target.requested, target.fresh, target.served].filter( + (value): value is string => Boolean(value), + ); + if (new Set(identities).size < 2) continue; + const parts: string[] = []; + if (target.requested) parts.push(`requested ${target.requested}`); + if (target.fresh) parts.push(`fresh ${target.fresh}`); + if (target.served) parts.push(`served ${target.served}`); + if (parts.length > 0) lines.push(`Target: ${parts.join("; ")}`); + } +} - const trailer = buildTrailer(payload, { - includeWarnings: !completedEmpty, - includeSourceStatus: !completedEmpty, - }); - if (trailer.length > 0) { - lines.push(""); - for (const line of trailer) lines.push(line); +function appendPresentationSources( + lines: string[], + groups: UnifiedSearchSourceGroup[], + trustLimits: UnifiedSearchTrustLimit[], +): void { + const states: Array<{ + state: UnifiedSearchSourceEntry["state"]; + label: string; + }> = [ + { state: "waiting", label: "Waiting" }, + { state: "searched", label: "Searched" }, + { state: "available_not_searched", label: "Available but not searched" }, + { state: "unavailable", label: "Unavailable" }, + ]; + for (const { state, label } of states) { + const entries = groups.flatMap((group) => + group.entries + .filter((entry) => entry.state === state) + .map((entry) => ({ group, entry })), + ); + if (entries.length === 0) continue; + const values = entries.map(({ group, entry }) => + formatSourceReadiness(group, entry, state, trustLimits), + ); + const unique = [...new Set(values)]; + lines.push(`${label}: ${unique.join(", ")}`); } +} - return lines.join("\n"); +function formatSourceReadiness( + group: UnifiedSearchSourceGroup, + entry: UnifiedSearchSourceEntry, + state: UnifiedSearchSourceEntry["state"], + trustLimits: UnifiedSearchTrustLimit[], +): string { + const sourceLabel = sourceGroupLabel(group.kind); + if (state === "unavailable") return `${sourceLabel} (${entry.target})`; + const coverage = trustLimits.find( + (limit): limit is Extract => + limit.kind === "coverage" && + limit.source === group.kind && + limit.target === entry.target, + ); + const coverageDetails = coverage ? formatCoverageLimit(coverage) : undefined; + if (state === "searched") { + return coverageDetails + ? `${sourceLabel} (${coverageDetails})` + : sourceLabel; + } + if (state === "waiting") return sourceLabel; + const identity = + group.kind === "site_docs" + ? `${entry.siteKey ?? entry.siteUrl ?? entry.target} docs` + : `${sourceLabel} (${entry.target})`; + return coverageDetails ? `${identity} (${coverageDetails})` : identity; +} + +function sourceGroupLabel(kind: UnifiedSearchSourceGroup["kind"]): string { + switch (kind) { + case "repository_docs": + return "repository docs"; + case "site_docs": + return "site docs"; + case "code": + return "code"; + } +} + +function formatCoverageLimit( + limit: Extract, +): string { + const details: string[] = [limit.state]; + if (typeof limit.pagesCrawled === "number") { + details.unshift(`${limit.pagesCrawled.toLocaleString("en-US")} pages`); + } + return details.join("; "); +} + +function appendPresentationTrust( + lines: string[], + trustLimits: UnifiedSearchTrustLimit[], +): void { + const trust = trustLimits.filter((limit) => limit.kind !== "source"); + for (const limit of trust) { + switch (limit.kind) { + case "stale": + lines.push( + `Evidence: ${limit.requestedTarget ? `requested ${limit.requestedTarget}; ` : ""}served older snapshot ${limit.servedTarget ?? limit.target ?? "unknown target"}${limit.freshTarget ? ` while ${limit.freshTarget} indexes` : ""}.`, + ); + break; + case "provisional": + lines.push("Evidence: provisional snapshot; indexing continues."); + break; + case "coverage": + break; + case "constraint": + case "mutable_evidence": + if (limit.kind === "mutable_evidence") + lines.push("Evidence may change."); + break; + } + } +} + +function appendPresentationWarnings( + lines: string[], + warnings: UnifiedSearchWarning[], +): void { + if (warnings.length === 0) return; + lines.push("Warnings:"); + for (const warning of warnings) { + if (warning.kind === "query") lines.push(` - ${warning.message}`); + else { + const label = warning.kind.replaceAll("_", " "); + const source = warning.source ? ` (${warning.source})` : ""; + lines.push( + ` - ${capitalize(label)}${source}: ${warning.values.join(", ")}`, + ); + } + } +} + +function appendPresentationAlternatives( + lines: string[], + presentation: UnifiedSearchPresentation, +): void { + const alternatives = mergePresentationAlternatives(presentation.alternatives); + for (const alternative of alternatives) { + const categories: string[] = []; + if (alternative.versions.length > 0) { + categories.push( + `versions ${alternative.versions.map((entry) => entry.version ?? entry.ref).join(", ")}${formatRemaining(alternative.versionsRemaining)}`, + ); + } + if (alternative.refs.length > 0) { + categories.push( + `refs ${alternative.refs.map((entry) => entry.ref).join(", ")}${formatRemaining(alternative.refsRemaining)}`, + ); + } + if (alternative.suggestedRefs.length > 0) { + categories.push( + `suggested refs ${alternative.suggestedRefs.map((entry) => entry.ref).join(", ")}${formatRemaining(alternative.suggestedRefsRemaining)}`, + ); + } + if (categories.length > 0) { + lines.push( + `Indexed alternatives${alternatives.length > 1 && alternative.target ? ` for ${alternative.target}` : ""}: ${categories.join("; ")}`, + ); + } + } +} + +interface DisplayAlternativeFacts { + target?: string; + versions: UnifiedSearchPresentation["alternatives"][number]["versions"]; + versionsRemaining: number; + refs: UnifiedSearchPresentation["alternatives"][number]["refs"]; + refsRemaining: number; + suggestedRefs: UnifiedSearchPresentation["alternatives"][number]["suggestedRefs"]; + suggestedRefsRemaining: number; +} + +function mergePresentationAlternatives( + alternatives: UnifiedSearchPresentation["alternatives"], +): DisplayAlternativeFacts[] { + const merged: DisplayAlternativeFacts[] = []; + for (const alternative of alternatives) { + const key = alternative.target + ? alternative.target.replace(/@[^/@]+$/, "") + : ""; + let display = merged.find( + (candidate) => + (candidate.target ? candidate.target.replace(/@[^/@]+$/, "") : "") === + key, + ); + if (!display) { + display = { + target: alternative.target, + versions: [], + versionsRemaining: 0, + refs: [], + refsRemaining: 0, + suggestedRefs: [], + suggestedRefsRemaining: 0, + }; + merged.push(display); + } + appendBoundedAlternatives( + display.versions, + alternative.versions, + (remaining) => (display.versionsRemaining += remaining), + ); + display.versionsRemaining = Math.max( + display.versionsRemaining, + alternative.versionsRemaining, + ); + appendBoundedAlternatives( + display.refs, + alternative.refs, + (remaining) => (display.refsRemaining += remaining), + ); + display.refsRemaining = Math.max( + display.refsRemaining, + alternative.refsRemaining, + ); + appendBoundedAlternatives( + display.suggestedRefs, + alternative.suggestedRefs, + (remaining) => (display.suggestedRefsRemaining += remaining), + ); + display.suggestedRefsRemaining = Math.max( + display.suggestedRefsRemaining, + alternative.suggestedRefsRemaining, + ); + } + return merged; +} + +function appendBoundedAlternatives( + target: UnifiedSearchAlternativeFacts["versions"], + values: UnifiedSearchAlternativeFacts["versions"], + addRemaining: (remaining: number) => void, +): void { + for (const value of values) { + const duplicate = target.some( + (candidate) => + candidate.version === value.version && candidate.ref === value.ref, + ); + if (duplicate) continue; + if (target.length < 3) target.push(value); + else addRemaining(1); + } +} + +function formatRemaining(count: number): string { + return count > 0 ? ` +${count} more` : ""; +} + +function appendPresentationAction( + lines: string[], + presentation: UnifiedSearchPresentation, +): void { + const action = presentation.action; + if (action.kind === "none") { + if (presentation.availability.kind === "empty") { + lines.push( + hasEvidenceLimit(presentation.trustLimits) + ? "Do not repeat immediately." + : "Do not repeat this search unchanged.", + ); + } + return; + } + if (action.kind === "poll" || action.kind === "status") { + lines.push( + action.kind === "status" + ? "Do not repeat immediately." + : "Do not repeat search.", + ); + lines.push( + `Next: search_status search_ref=${JSON.stringify(action.searchRef)} wait_timeout_ms=${DEFAULT_WAIT_TIMEOUT_MS}`, + ); + return; + } + if (action.kind === "new_search") { + if ( + presentation.lifecycle.kind === "terminal" || + presentation.lifecycle.kind === "unknown" + ) { + lines.push("Do not call search_status again for this session."); + } else if (presentation.availability.kind === "empty") { + lines.push("Do not repeat immediately."); + } + lines.push("Next: rerun search later."); + return; + } + if (action.kind === "indexed_alternative") { + if (presentation.availability.kind === "empty") { + lines.push("Do not repeat immediately."); + } + lines.push( + `Next: search indexed ${action.category} ${action.value}${action.target ? ` for ${action.target}` : ""}.`, + ); + return; + } + if (action.kind === "query_rewrite") { + lines.push( + hasEvidenceLimit(presentation.trustLimits) + ? "Do not repeat immediately." + : "Do not repeat this search unchanged.", + ); + lines.push(`Next: ${action.rewrites.map(formatRewrite).join("; ")}.`); + } +} + +function hasEvidenceLimit(trustLimits: UnifiedSearchTrustLimit[]): boolean { + return trustLimits.some( + (limit) => + limit.kind === "mutable_evidence" || + limit.kind === "stale" || + limit.kind === "provisional" || + limit.kind === "coverage" || + limit.kind === "source", + ); +} + +function formatRewrite( + rewrite: NonNullable< + Extract + >["rewrites"][number], +): string { + switch (rewrite) { + case "shorter_or_broader": + return "shorten or broaden query"; + case "remove_filters": + return "remove restrictive filters"; + case "symbol": + return 'use source="symbol"'; + case "code_grep": + return "use code_grep"; + case "site_shorter_or_broader": + return "shorten or broaden site query"; + } +} + +function capitalize(value: string): string { + return value.length > 0 + ? `${value[0]?.toUpperCase()}${value.slice(1)}` + : value; } export function noHitsYetMessage( @@ -124,19 +550,6 @@ export function renderUnifiedSearchError( return lines.join("\n"); } -function buildHeader(payload: SearchSuccessPayload): string { - const count = payload.results.length; - const status = payload.completed - ? `${count} hit${count === 1 ? "" : "s"}` - : `${count} partial`; - const parts = [`search${SEP}${status}`]; - parts.push(`query=${quote(payload.query.raw)}`); - if (!payload.completed) { - parts.push(`searchRef=${payload.searchRef}`); - } - return parts.join(SEP); -} - export function appendUnifiedSearchHits( lines: string[], hits: UnifiedSearchHitPayload[], From 1c70d5e948f9bf3ed77a2abe1703fd5a12c30ee5 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:49:31 +0300 Subject: [PATCH 07/46] test: align tool and smoke search contracts Update tool and parity expectations for additive partialResults, and enforce concise outcome-first MCP search text with action-contained references in smoke coverage. --- packages/mcp/src/smoke-test.test.ts | 25 +++- packages/mcp/src/smoke-test.ts | 43 +++++- packages/mcp/src/tools/search-status.test.ts | 136 ++++++++++++------- packages/mcp/src/tools/search.test.ts | 47 +++++-- src/tools/search-parity.test.ts | 31 ++++- 5 files changed, 208 insertions(+), 74 deletions(-) diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index c594bc36..0e674cc2 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -156,6 +156,21 @@ describe("runMcpSmoke", () => { args: { solution_id: "", accepted: true }, }); }); + + it("rejects search action references outside a Next line", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + 'Indexing - no result snapshot returned yet\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000\nsearch_ref=leaked', + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: search_ref= must appear at most once", + ); + }); }); function smokeResponse( @@ -210,7 +225,9 @@ function smokeResponse( case "code_grep": return textResult("package.json: express"); case "search": - return textResult("code_read target=express path=package.json"); + return textResult( + 'Indexing - no result snapshot returned yet\nReady: 0/1 targets\nDo not repeat search.\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000', + ); case "search_status": return errorResult("NOT_FOUND"); case "feedback": @@ -259,7 +276,11 @@ function smokeJsonResponse( case "code_grep": return jsonResult({ matches: [] }); case "search": - return jsonResult({ hits: [] }); + return jsonResult({ + completed: false, + searchRef: "smoke-ref", + progress: { status: "INDEXING", targetsReady: 0, targetsTotal: 1 }, + }); case "search_status": return jsonResult({ completed: true }); default: diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 6e2fdb86..0c5efea0 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -180,6 +180,42 @@ export function assertDefaultText( return text; } +function assertSearchDefaultText(text: string, context: string): void { + const lines = text.split("\n"); + const firstLine = lines[0]?.trim() ?? ""; + assert(firstLine.length > 0, `${context}: missing outcome first line`); + assert( + !firstLine.startsWith("search | ") && + !firstLine.startsWith("search_status | "), + `${context}: legacy header precedes outcome`, + ); + assert( + !lines.some((line) => /^status\s*:/i.test(line.trim())), + `${context}: duplicated lifecycle status line`, + ); + assert(!text.includes("searchRef="), `${context}: leaked searchRef=`); + assert(!text.includes("indexingRef"), `${context}: leaked indexingRef`); + + const searchRefOccurrences = text.match(/search_ref=/g)?.length ?? 0; + assert( + searchRefOccurrences <= 1, + `${context}: search_ref= must appear at most once`, + ); + if (searchRefOccurrences === 1) { + const refLine = lines.find((line) => line.includes("search_ref=")); + assert( + refLine?.trimStart().startsWith("Next:"), + `${context}: search_ref= must appear only on a Next line`, + ); + } + assert( + text.includes("code_read") || + text.includes("docs_read") || + text.includes("search_status"), + `${context}: missing ready-to-call result or status follow-up`, + ); +} + export function assertJsonResult( result: McpSmokeToolResult, context: string, @@ -740,12 +776,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { }), "search default", ); - assert( - searchText.includes("code_read") || - searchText.includes("docs_read") || - searchText.includes("search_status"), - "search default missing ready-to-call follow-up", - ); + assertSearchDefaultText(searchText, "search default"); const searchJson = assertJsonResult( await callTool(caller, "search", { diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index b7cbcd96..19a55793 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -58,6 +58,9 @@ describe("searchStatusTool", () => { searchRef: "search-ref-123", progress: expect.objectContaining({ status: "SEARCHING" }), }); + expect(JSON.parse(result.content[0]?.text ?? "{}")).not.toHaveProperty( + "partialResults", + ); }); it("preserves provisional hits and the search reference for continuation", async () => { @@ -106,6 +109,7 @@ describe("searchStatusTool", () => { searchRef: "search-ref-provisional", result: { results: [{ type: "repository_code" }], + partialResults: false, sourceStatus: [ { codeIndexState: "PROVISIONAL", @@ -120,8 +124,11 @@ describe("searchStatusTool", () => { }); const text = await tool.handler({ search_ref: incomplete.searchRef }, {}); - expect(text.content[0]?.text).toContain("provisional (still indexing)"); - expect(text.content[0]?.text).toContain("search-ref-provisional"); + expect(text.content[0]?.text).toContain("Evidence: provisional snapshot"); + expect(text.content[0]?.text).toContain( + 'Next: search_status search_ref="search-ref-provisional" wait_timeout_ms=20000', + ); + expect(text.content[0]?.text).not.toContain("indexingRef"); }); it("describes partial-result follow-up behavior", () => { @@ -209,9 +216,32 @@ describe("searchStatusTool", () => { expect(payload.completed).toBe(true); expect(payload.searchRef).toBe(defaultUnifiedSearchOutcome.searchRef); expect(payload.result.results).toHaveLength(1); + expect(payload.result.partialResults).toBe(false); expect(payload).not.toHaveProperty("query"); }); + it("preserves partialResults=true in a stored status result", async () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + const outcome = { + ...defaultUnifiedSearchOutcome, + result: { ...defaultUnifiedSearchOutcome.result, partialResults: true }, + }; + const tool = createSearchStatusTool( + createMockCodeNavigationService({ + searchStatus: mock(() => Promise.resolve(outcome)), + }), + ); + + const result = await tool.handler( + { search_ref: "search-ref-123", format: "json" }, + {}, + ); + const payload = JSON.parse(result.content[0]?.text ?? "{}"); + expect(payload.result.partialResults).toBe(true); + }); + it("preserves stored documentation contributor metadata in JSON and text", async () => { const tool = createSearchStatusTool( createMockCodeNavigationService({ @@ -238,10 +268,8 @@ describe("searchStatusTool", () => { ); const text = await tool.handler({ search_ref: "search-ref-docs" }, {}); - expect(text.content[0]?.text).toContain("documentation sources:"); - expect(text.content[0]?.text).toContain( - "site expressjs.com/en/guide - not ready, so it was not searched", - ); + expect(text.content[0]?.text).toContain("Waiting: site docs"); + expect(text.content[0]?.text).toContain("Searched: repository docs"); }); it("keeps completed empty JSON structured", async () => { @@ -320,11 +348,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-timeout" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("search_status | timeout | searchRef=ref-timeout"); - expect(text).not.toContain("search_status | indexing"); + expect(text).toContain("TIMEOUT - no result snapshot returned"); + expect(text).not.toContain("search_status |"); expect(text).toContain("Do not call search_status again for this session."); - expect(text).toContain("next: rerun search."); - expect(text).not.toContain("next: call search_status"); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("search_ref="); }); it("stops polling a failed search session", async () => { @@ -338,9 +366,10 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-failed" }, {}); const text = result.content[0]?.text ?? ""; + expect(text).toContain("FAILED - no result snapshot returned"); expect(text).toContain("Do not call search_status again for this session."); - expect(text).toContain("next: rerun search."); - expect(text).not.toContain("next: call search_status"); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("search_ref="); }); it("preserves evidence for a terminal deferred session without polling it", async () => { @@ -381,13 +410,11 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-deferred" }, {}); const text = textResult.content[0]?.text ?? ""; - expect(text).toContain("search_status | deferred | searchRef=ref-deferred"); - expect(text).toContain( - "Background lifecycle work continues outside this search session.", - ); - expect(text).toContain("Stored evidence remains usable."); - expect(text).toContain("next: rerun search later for a fresher snapshot."); - expect(text).not.toContain("next: call search_status"); + expect(text).toContain("DEFERRED - 1 result returned"); + expect(text).toContain("Evidence may change."); + expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); expect(text).not.toContain("Indexing in progress"); }); @@ -405,13 +432,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-deferred-empty" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain( - "search_status | deferred | searchRef=ref-deferred-empty", - ); - expect(text).toContain("next: rerun search later for a fresher snapshot."); + expect(text).toContain("DEFERRED - no result snapshot returned"); + expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("No hits"); expect(text).not.toContain("Indexing in progress"); - expect(text).not.toContain("next: call search_status"); + expect(text).not.toContain("search_ref="); }); it("preserves an unrecognized status and evidence without polling it", async () => { @@ -446,20 +471,19 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-future" }, {}); const text = textResult.content[0]?.text ?? ""; - expect(text).toContain( - "search_status | future_session_state | searchRef=ref-future", - ); - expect(text).toContain("This client does not recognize that status."); - expect(text).toContain("Stored evidence remains usable."); - expect(text).not.toContain("next: call search_status"); + expect(text).toContain("FUTURE_SESSION_STATE - 1 result returned"); + expect(text).toContain("Evidence may change."); + expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); expect(text).not.toContain("Indexing in progress"); - expect(text).not.toContain("terminal"); + expect(text).not.toContain("status:"); }); it.each([ - ["FAILED", "No hits - search failed."], - ["TIMEOUT", "No hits - search timed out."], + ["FAILED", "FAILED - no results returned"], + ["TIMEOUT", "TIMEOUT - no results returned"], ] as const)( "does not promise future hits for a terminal %s partial result", async (status, expectedMessage) => { @@ -497,7 +521,7 @@ describe("searchStatusTool", () => { }, ); - it("renders site recovery guidance for incomplete results without hits", async () => { + it("renders the site outcome without stale recovery prose", async () => { if (defaultUnifiedSearchOutcome.state !== "completed") { throw new Error("expected completed outcome fixture"); } @@ -529,10 +553,14 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: incomplete.searchRef }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("No hits yet"); - expect(text).toContain("source notes:"); - expect(text).toContain("Suggested site targets: site:docs.example.com"); - expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain( + "Indexing site:example.com - no results returned yet", + ); + expect(text).toContain("Searched: site docs"); + expect(text).toContain( + 'Next: search_status search_ref="ref-site-recovery" wait_timeout_ms=20000', + ); + expect(text).not.toContain("Suggested site targets"); }); it("surfaces progress freshness warnings", async () => { @@ -580,9 +608,10 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-stale" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("warnings:"); const warning = "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes."; + expect(text).toContain("Target: requested npm:express latest"); + expect(text).toContain("Evidence: " + warning); expect(text).toContain(warning); expect(text.split(warning)).toHaveLength(2); }); @@ -629,14 +658,14 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("source notes:"); expect(text).toContain( - "Using recent indexed snapshot while branch resolution is deferred", + "Evidence: served older snapshot npm:express@4.18.2.", ); - expect(text).toContain("queryable now: versions=4.18.2@v4.18.2"); + expect(text).toContain("Indexed alternatives: versions 4.18.2"); + expect(text).not.toContain("ref_resolution_deferred"); }); - it("renders structured site recovery guidance in completed text", async () => { + it("renders completed site emptiness without stale recovery prose", async () => { if (defaultUnifiedSearchOutcome.state !== "completed") { throw new Error("expected completed outcome fixture"); } @@ -672,8 +701,9 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("Suggested site targets: site:example.com/docs"); - expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain("No results returned from site:example.com"); + expect(text).toContain("Next: shorten or broaden site query."); + expect(text).not.toContain("Suggested site targets"); }); it("renders terminal source status compactly in completed text", async () => { @@ -724,8 +754,10 @@ describe("searchStatusTool", () => { ); const text = result.content[0]?.text ?? ""; expect(text).toContain( - "code (github:githits-com/no-such-repo) | Repository ref cannot be resolved (UNRESOLVABLE)", + "No results returned from github:githits-com/no-such-repo", ); + expect(text).toContain("Searched: code"); + expect(text).not.toContain("Repository ref cannot be resolved"); expect(text).not.toContain("state=indexing"); }); @@ -742,13 +774,14 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(result.isError).toBeUndefined(); - expect(text).toContain("search_status | searching | searchRef=ref-text"); - expect(text).toContain("progress: SEARCHING, 0/1 targets ready"); + expect(text).toContain("Searching - no result snapshot returned yet"); + expect(text).toContain("Ready: 0/1 targets"); expect(text).toContain("Do not repeat search."); expect(text).toContain( - 'next: call search_status with search_ref="ref-text" and wait_timeout_ms=20000.', + 'Next: search_status search_ref="ref-text" wait_timeout_ms=20000', ); - expect(text).not.toContain("searchRef=ref-text to follow up"); + expect(text).not.toContain("search_status |"); + expect(text).not.toContain("searchRef="); expect(() => JSON.parse(text)).toThrow(); }); @@ -774,8 +807,9 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-alternatives" }, {}); const text = result.content[0]?.text ?? ""; + expect(text).toContain("Indexed alternatives: versions 4.18.2; refs main"); expect(text).toContain( - "queryable now: versions=4.18.2@v4.18.2 | refs=main", + 'Next: search_status search_ref="ref-alternatives" wait_timeout_ms=20000', ); expect(text).toContain("Do not repeat search."); expect(text).not.toContain("allow_partial_results: true"); diff --git a/packages/mcp/src/tools/search.test.ts b/packages/mcp/src/tools/search.test.ts index a0849a45..bc0cfe01 100644 --- a/packages/mcp/src/tools/search.test.ts +++ b/packages/mcp/src/tools/search.test.ts @@ -79,8 +79,41 @@ describe("searchTool", () => { const payload = JSON.parse(result.content[0]?.text ?? "{}"); expect(payload.completed).toBe(true); expect(payload.results[0].target).toBe("npm:express@4.18.2"); + expect(payload.partialResults).toBe(false); }); + it.each([false, true] as const)( + "preserves partialResults=%s in initial JSON", + async (partialResults) => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + const outcome: UnifiedSearchOutcome = { + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + partialResults, + }, + }; + const tool = createSearchTool( + createMockCodeNavigationService({ + search: mock(() => Promise.resolve(outcome)), + }), + ); + + const result = await tool.handler( + { + query: "router", + target: { registry: "npm", package_name: "express" }, + format: "json", + }, + {}, + ); + const payload = JSON.parse(result.content[0]?.text ?? "{}"); + expect(payload.partialResults).toBe(partialResults); + }, + ); + it("returns documentation contributors and evidence metadata in JSON and text", async () => { const tool = createSearchTool( createMockCodeNavigationService({ @@ -115,10 +148,8 @@ describe("searchTool", () => { }, {}, ); - expect(text.content[0]?.text).toContain("documentation sources:"); - expect(text.content[0]?.text).toContain( - "site expressjs.com/en/guide - not ready, so it was not searched", - ); + expect(text.content[0]?.text).toContain("Waiting: site docs"); + expect(text.content[0]?.text).toContain("Searched: repository docs"); }); it("passes compiled request through to code navigation service", async () => { @@ -572,7 +603,7 @@ describe("searchTool", () => { ); expect(result.isError).toBeUndefined(); const text = result.content[0]?.text ?? ""; - expect(text).toContain("search | "); + expect(text.split("\n")[0]).not.toContain("search | "); expect(() => JSON.parse(text)).toThrow(); }); @@ -588,8 +619,8 @@ describe("searchTool", () => { ); expect(result.isError).toBeUndefined(); const text = result.content[0]?.text ?? ""; - expect(text).toContain("search | "); - expect(text).toContain('query="router middleware"'); + expect(text.split("\n")[0]).not.toContain("search | "); + expect(text.split("\n")[0]).toContain("1 result"); // Confirm the text payload is not valid JSON. expect(() => JSON.parse(text)).toThrow(); }); @@ -605,7 +636,7 @@ describe("searchTool", () => { {}, ); const text = result.content[0]?.text ?? ""; - expect(text).toContain("search | "); + expect(text.split("\n")[0]).not.toContain("search | "); }); it("keeps the JSON envelope when format=json (explicit)", async () => { diff --git a/src/tools/search-parity.test.ts b/src/tools/search-parity.test.ts index bdef9d22..76905123 100644 --- a/src/tools/search-parity.test.ts +++ b/src/tools/search-parity.test.ts @@ -6,7 +6,17 @@ import { } from "../services/test-helpers.js"; import { createParityMcpTool } from "./parity-test-helpers.js"; -async function cliJson(): Promise { +function outcomeWithPartial(partialResults: boolean) { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + return { + ...defaultUnifiedSearchOutcome, + result: { ...defaultUnifiedSearchOutcome.result, partialResults }, + }; +} + +async function cliJson(partialResults: boolean): Promise { const logSpy = spyOn(console, "log").mockImplementation(() => {}); try { await searchAction( @@ -14,7 +24,9 @@ async function cliJson(): Promise { { in: ["npm:express"], json: true }, { codeNavigationService: createMockCodeNavigationService({ - search: mock(() => Promise.resolve(defaultUnifiedSearchOutcome)), + search: mock(() => + Promise.resolve(outcomeWithPartial(partialResults)), + ), }), codeNavigationUrl: "https://pkgseer.dev", hasValidToken: true, @@ -27,10 +39,10 @@ async function cliJson(): Promise { } } -async function mcpJson(): Promise { +async function mcpJson(partialResults: boolean): Promise { const tool = createParityMcpTool("search", { codeNavigationService: createMockCodeNavigationService({ - search: mock(() => Promise.resolve(defaultUnifiedSearchOutcome)), + search: mock(() => Promise.resolve(outcomeWithPartial(partialResults))), }), }); const result = await tool.handler( @@ -41,7 +53,12 @@ async function mcpJson(): Promise { } describe("search parity", () => { - it("PARITY-JSON-KEYS: CLI === MCP", async () => { - expect(await cliJson()).toEqual(await mcpJson()); - }); + it.each([false, true] as const)( + "PARITY-JSON-KEYS: CLI === MCP with partialResults=%s", + async (partialResults) => { + expect(await cliJson(partialResults)).toEqual( + await mcpJson(partialResults), + ); + }, + ); }); From 42ea8205c718b6f4bf832a16eda1e89a6bf50683 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 07:50:27 +0300 Subject: [PATCH 08/46] test: reject all legacy search headers Make the smoke invariant catch either legacy search header spelling regardless of trailing whitespace. --- packages/mcp/src/smoke-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 0c5efea0..4b2d6cc7 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -185,8 +185,8 @@ function assertSearchDefaultText(text: string, context: string): void { const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); assert( - !firstLine.startsWith("search | ") && - !firstLine.startsWith("search_status | "), + !firstLine.startsWith("search |") && + !firstLine.startsWith("search_status |"), `${context}: legacy header precedes outcome`, ); assert( From 020520ebbb03b55a8f18b900854c0ae8118c45e5 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 08:08:48 +0300 Subject: [PATCH 09/46] fix: preserve search recovery and source provenance Keep suggested site targets as bounded presentation facts with safe retry actions, and retain source identity and target context when grouping readiness. Consolidate alternative bounding in the shared model and remove redundant renderer projection machinery. --- .../unified-search-presentation.test.ts | 104 +++++++--- .../src/shared/unified-search-presentation.ts | 179 +++++++++++------ .../src/shared/unified-search-text.test.ts | 142 +++++++++++++- .../mcp/src/shared/unified-search-text.ts | 180 +++++++++--------- packages/mcp/src/tools/search-status.test.ts | 17 +- 5 files changed, 444 insertions(+), 178 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index b000a85e..29172a81 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -94,34 +94,32 @@ function source( } describe("projectUnifiedSearchPresentation", () => { - it.each([ - ["PENDING", "preparing"], - ["INDEXING", "indexing"], - ["SEARCHING", "searching"], - ] as const)("keeps active lifecycle %s distinct", (status, kind) => { - const presentation = projectUnifiedSearchPresentation( - incomplete({ - progress: { - status, - targetsReady: 0, - targetsTotal: 1, - elapsedMs: 200, - }, - }), - ); + it.each(["PENDING", "INDEXING", "SEARCHING"] as const)( + "keeps active lifecycle %s distinct", + (status) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }, + }), + ); - expect(presentation.lifecycle).toEqual({ kind: "active", status }); - expect(presentation.lifecycleHeadline).toBe(kind); - expect(presentation.progress).toEqual({ - targetsReady: 0, - targetsTotal: 1, - elapsedMs: 200, - }); - expect(presentation.action).toEqual({ - kind: "poll", - searchRef: "search-ref-1", - }); - }); + expect(presentation.lifecycle).toEqual({ kind: "active", status }); + expect(presentation.progress).toEqual({ + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }); + expect(presentation.action).toEqual({ + kind: "poll", + searchRef: "search-ref-1", + }); + }, + ); it.each(["DEFERRED", "TIMEOUT", "FAILED"] as const)( "keeps terminal lifecycle %s distinct and non-polling", @@ -783,6 +781,58 @@ describe("projectUnifiedSearchPresentation", () => { ); }); + it("retains site suggestions while keeping active and terminal actions safe", () => { + const siteStatus = source({ + source: "docs", + targetLabel: "site:example.com", + suggestedSiteTargets: ["site:docs.example.com", "site:api.example.com"], + suggestedSiteTargetsTruncated: true, + }); + const expectedSuggestions = [ + { + target: "site:example.com", + suggestions: ["site:docs.example.com", "site:api.example.com"], + truncated: true, + }, + ]; + + const active = projectUnifiedSearchPresentation( + incomplete({ partialResults: false, sourceStatus: [siteStatus] }), + ); + expect(active.siteSuggestions).toEqual(expectedSuggestions); + expect(active.action).toEqual({ + kind: "poll", + searchRef: "search-ref-1", + }); + + const completedPresentation = projectUnifiedSearchPresentation( + completed({ results: [], sourceStatus: [siteStatus] }), + ); + expect(completedPresentation.siteSuggestions).toEqual(expectedSuggestions); + expect(completedPresentation.action).toEqual({ + kind: "site_retry", + }); + + for (const status of ["DEFERRED", "FUTURE_SESSION_STATE"] as const) { + const terminal = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + sourceStatus: [siteStatus], + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + }), + ); + expect(terminal.action).toEqual({ + kind: "site_retry", + }); + expect(terminal.action).not.toHaveProperty("searchRef"); + } + }); + it("classifies coverage and structured query constraints without promoted warnings", () => { const presentation = projectUnifiedSearchPresentation( completed({ diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index add2d8a0..1a20ce20 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -90,6 +90,12 @@ export interface UnifiedSearchAlternativeFacts { suggestedRefsRemaining: number; } +export interface UnifiedSearchSiteSuggestionFacts { + target: string; + suggestions: string[]; + truncated: boolean; +} + export type UnifiedSearchConstraintKind = | "ignored_filter" | "incompatible_filter" @@ -140,6 +146,7 @@ export type UnifiedSearchAction = | { kind: "poll"; searchRef: string } | { kind: "status"; searchRef: string } | { kind: "new_search" } + | { kind: "site_retry" } | { kind: "indexed_alternative"; target?: string; @@ -162,13 +169,13 @@ export type UnifiedSearchRewriteKind = export interface UnifiedSearchPresentation { availability: UnifiedSearchAvailability; lifecycle: UnifiedSearchLifecycle; - lifecycleHeadline: "preparing" | "indexing" | "searching" | undefined; query?: UnifiedSearchQueryEcho; searchRef?: string; progress?: UnifiedSearchProgressPresentation; targets: UnifiedSearchTargetPresentation[]; hasMore: boolean; sources: UnifiedSearchSourceGroup[]; + siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; warnings: UnifiedSearchWarning[]; alternatives: UnifiedSearchAlternativeFacts[]; @@ -184,10 +191,6 @@ interface SnapshotFacts { evidenceNotice?: string; } -interface ProgressFacts { - progress?: UnifiedSearchProgressPayload; -} - interface CandidateSet { target?: string; versions: UnifiedSearchAlternative[]; @@ -201,35 +204,35 @@ export function projectUnifiedSearchPresentation( payload: UnifiedSearchPresentationInput, ): UnifiedSearchPresentation { const snapshot = extractSnapshot(payload); - const progress = extractProgress(payload); - const lifecycle = projectLifecycle(payload, progress.progress); + const progress = "progress" in payload ? payload.progress : undefined; + const lifecycle = projectLifecycle(payload, progress); const availability = projectAvailability(snapshot, lifecycle); const sourceStatus = snapshot?.sourceStatus; const sources = projectSources(sourceStatus); + const siteSuggestions = projectSiteSuggestions(sourceStatus); const trustLimits = projectTrustLimits(snapshot, sources, sourceStatus); const warnings = projectWarnings(snapshot?.query, sourceStatus); - const alternatives = projectAlternatives(progress.progress, sourceStatus); + const alternatives = projectAlternatives(progress, sourceStatus); return { availability, lifecycle, - lifecycleHeadline: lifecycleHeadline(lifecycle), query: snapshot?.query ?? extractQuery(payload), searchRef: extractSearchRef(payload), - progress: projectProgress(progress.progress), - targets: projectTargets(progress.progress), + progress: projectProgress(progress), + targets: projectTargets(progress), hasMore: snapshot?.hasMore ?? false, sources, + siteSuggestions, trustLimits, warnings, alternatives, action: projectAction({ payload, snapshot, - progress: progress.progress, lifecycle, availability, - sources, + siteSuggestions, trustLimits, alternatives, }), @@ -263,12 +266,6 @@ function extractSnapshot( return undefined; } -function extractProgress( - payload: UnifiedSearchPresentationInput, -): ProgressFacts { - return "progress" in payload ? { progress: payload.progress } : {}; -} - function extractQuery( payload: UnifiedSearchPresentationInput, ): UnifiedSearchQueryEcho | undefined { @@ -331,20 +328,6 @@ function projectLifecycle( } } -function lifecycleHeadline( - lifecycle: UnifiedSearchLifecycle, -): "preparing" | "indexing" | "searching" | undefined { - if (lifecycle.kind !== "active") return undefined; - switch (lifecycle.status) { - case "PENDING": - return "preparing"; - case "INDEXING": - return "indexing"; - case "SEARCHING": - return "searching"; - } -} - function projectAvailability( snapshot: SnapshotFacts | undefined, lifecycle: UnifiedSearchLifecycle, @@ -389,13 +372,29 @@ function projectSources( const kind = sourceKind(entry); appendSourceEntry(groups, kind, { state: sourceState(entry), - target: sourceTarget(entry), + ...sourceIdentity(entry, kind), resultCount: entry.resultCount, }); } return groups; } +function projectSiteSuggestions( + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): UnifiedSearchSiteSuggestionFacts[] { + return (sourceStatus ?? []) + .filter( + (entry) => + Boolean(entry.suggestedSiteTargets?.length) || + entry.suggestedSiteTargetsTruncated === true, + ) + .map((entry) => ({ + target: sourceTarget(entry), + suggestions: [...(entry.suggestedSiteTargets ?? [])], + truncated: entry.suggestedSiteTargetsTruncated === true, + })); +} + function appendSourceEntry( groups: UnifiedSearchSourceGroup[], kind: UnifiedSearchSourceKind, @@ -446,6 +445,35 @@ function contributorIdentity( }; } +function sourceIdentity( + entry: UnifiedSearchSourceStatusPayload, + kind: UnifiedSearchSourceKind, +): Pick< + UnifiedSearchSourceEntry, + | "target" + | "contextTarget" + | "repositoryUrl" + | "commitSha" + | "siteKey" + | "siteUrl" +> { + const target = sourceTarget(entry); + const contextTarget = entry.requestedTarget ?? entry.freshTarget; + const context = + contextTarget && contextTarget !== target ? { contextTarget } : {}; + const served = entry.targetResolution?.served; + const identity = + kind === "repository_docs" + ? { + ...(served?.repoUrl ? { repositoryUrl: served.repoUrl } : {}), + ...(served?.commitSha ? { commitSha: served.commitSha } : {}), + } + : kind === "site_docs" && served?.site + ? { siteKey: served.site } + : {}; + return { target, ...context, ...identity }; +} + function sourceTarget(entry: UnifiedSearchSourceStatusPayload): string { return entry.servedTarget ?? entry.targetLabel; } @@ -507,7 +535,6 @@ function projectTrustLimits( } for (const hit of snapshot?.results ?? []) { - if (!isHitPayload(hit)) continue; if (hit.freshness === "STALE") { add({ kind: "stale", @@ -670,7 +697,7 @@ function projectAlternatives( suggestedRefs: resolution.suggestedRefs ?? [], }); } - return candidates + return mergeAlternativeCandidates(candidates) .filter( (candidate) => candidate.versions.length > 0 || @@ -687,31 +714,73 @@ function projectAlternatives( })); } +function mergeAlternativeCandidates( + candidates: CandidateSet[], +): CandidateSet[] { + const merged: CandidateSet[] = []; + for (const candidate of candidates) { + const key = candidate.target?.replace(/@[^/@]+$/, "") ?? ""; + const existing = merged.find( + (value) => (value.target?.replace(/@[^/@]+$/, "") ?? "") === key, + ); + if (existing) { + existing.versions.push(...candidate.versions); + existing.refs.push(...candidate.refs); + existing.suggestedRefs.push(...candidate.suggestedRefs); + } else { + merged.push({ + target: candidate.target, + versions: [...candidate.versions], + refs: [...candidate.refs], + suggestedRefs: [...candidate.suggestedRefs], + }); + } + } + return merged; +} + function boundedAlternatives( versions: UnifiedSearchAlternative[], refs: UnifiedSearchAlternative[], suggestedRefs: UnifiedSearchAlternative[], ): Omit { + const bounded = ( + values: UnifiedSearchAlternative[], + ): { + values: UnifiedSearchAlternative[]; + remaining: number; + } => { + const seen = new Set(); + const display: UnifiedSearchAlternative[] = []; + let remaining = 0; + for (const value of values) { + const key = `${value.version ?? ""}\u0000${value.ref}`; + if (seen.has(key)) continue; + seen.add(key); + if (display.length < MAX_ALTERNATIVES) display.push(value); + else remaining++; + } + return { values: display, remaining }; + }; + const versionFacts = bounded(versions); + const refFacts = bounded(refs); + const suggestedRefFacts = bounded(suggestedRefs); return { - versions: versions.slice(0, MAX_ALTERNATIVES), - versionsRemaining: Math.max(0, versions.length - MAX_ALTERNATIVES), - refs: refs.slice(0, MAX_ALTERNATIVES), - refsRemaining: Math.max(0, refs.length - MAX_ALTERNATIVES), - suggestedRefs: suggestedRefs.slice(0, MAX_ALTERNATIVES), - suggestedRefsRemaining: Math.max( - 0, - suggestedRefs.length - MAX_ALTERNATIVES, - ), + versions: versionFacts.values, + versionsRemaining: versionFacts.remaining, + refs: refFacts.values, + refsRemaining: refFacts.remaining, + suggestedRefs: suggestedRefFacts.values, + suggestedRefsRemaining: suggestedRefFacts.remaining, }; } interface ActionInput { payload: UnifiedSearchPresentationInput; snapshot: SnapshotFacts | undefined; - progress: UnifiedSearchProgressPayload | undefined; lifecycle: UnifiedSearchLifecycle; availability: UnifiedSearchAvailability; - sources: UnifiedSearchSourceGroup[]; + siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; alternatives: UnifiedSearchAlternativeFacts[]; } @@ -725,6 +794,9 @@ function projectAction(input: ActionInput): UnifiedSearchAction { input.lifecycle.kind === "terminal" || input.lifecycle.kind === "unknown" ) { + if (input.siteSuggestions.length > 0) { + return { kind: "site_retry" }; + } return { kind: "new_search" }; } if ( @@ -744,6 +816,9 @@ function projectAction(input: ActionInput): UnifiedSearchAction { if (alternative) return alternative; return { kind: "new_search" }; } + if (input.siteSuggestions.length > 0) { + return { kind: "site_retry" }; + } if ( input.trustLimits.some( (limit) => @@ -852,13 +927,3 @@ function isSiteTarget( entry.targetResolution?.served?.site, ); } - -function isHitPayload(value: unknown): value is { - target: string; - requestedTarget?: string; - freshTarget?: string; - servedTarget?: string; - freshness?: string; -} { - return Boolean(value && typeof value === "object" && "target" in value); -} diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 0ef4b023..88153158 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -270,6 +270,144 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("Do not repeat search.\nNext:"); }); + it("renders site suggestions once without selecting them during active polling", () => { + const sourceStatus = [ + source({ + source: "docs", + targetLabel: "site:example.com", + suggestedSiteTargets: ["site:docs.example.com", "site:api.example.com"], + suggestedSiteTargetsTruncated: true, + }), + ]; + const text = renderUnifiedSearchSuccess( + incomplete({ partialResults: false, sourceStatus }), + ); + + expect(text).toContain( + "Suggested site targets: site:docs.example.com, site:api.example.com", + ); + expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain("Do not repeat search.\nNext: search_status"); + expect(text).not.toContain("Next: retry one suggested site target"); + expect(text.match(/Suggested site targets:/g)).toHaveLength(1); + expect(text.match(/Additional site targets were omitted\./g)).toHaveLength( + 1, + ); + }); + + it("renders site retry guidance for completed and terminal site recovery", () => { + const sourceStatus = [ + source({ + source: "docs", + targetLabel: "site:example.com", + suggestedSiteTargets: ["site:docs.example.com"], + suggestedSiteTargetsTruncated: false, + }), + ]; + const completedText = renderUnifiedSearchSuccess( + completed([], { sourceStatus }), + ); + expect(completedText).toContain( + "Suggested site targets: site:docs.example.com", + ); + expect(completedText).toContain( + "Next: retry one suggested site target explicitly.", + ); + expect(completedText).not.toContain("search_status"); + + const terminalText = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + sourceStatus, + progress: { + status: "DEFERRED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + }), + ); + expect(terminalText).toContain( + "Suggested site targets: site:docs.example.com", + ); + expect(terminalText).toContain( + "Next: retry one suggested site target explicitly.", + ); + expect(terminalText).toContain( + "Do not call search_status again for this session.", + ); + expect(terminalText).not.toContain("Next: search_status"); + }); + + it("disambiguates multi-target readiness and preserves docs provenance", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "code", + targetLabel: "npm:one@1.0.0", + codeIndexState: "INDEXING", + }), + source({ + source: "code", + targetLabel: "npm:two@2.0.0", + codeIndexState: "INDEXING", + }), + source({ + source: "docs", + targetLabel: "npm:one@1.0.0", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/one/repo", + commitSha: "commit-one", + }, + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteKey: "docs.one.example", + }, + ], + }), + source({ + source: "docs", + targetLabel: "npm:two@2.0.0", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/two/repo", + commitSha: "commit-two", + }, + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteKey: "docs.two.example", + }, + ], + }), + ], + }), + ); + + expect(text).toContain( + "Waiting: code for npm:one@1.0.0, code for npm:two@2.0.0", + ); + expect(text).toContain( + "repository docs (https://github.com/one/repo @ commit-one) for npm:one@1.0.0", + ); + expect(text).toContain( + "repository docs (https://github.com/two/repo @ commit-two) for npm:two@2.0.0", + ); + expect(text).toContain("site docs (docs.one.example) for npm:one@1.0.0"); + expect(text).toContain("site docs (docs.two.example) for npm:two@2.0.0"); + }); + it.each([ ["PENDING", "Preparing"], ["INDEXING", "Indexing"], @@ -565,7 +703,9 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(text).toContain("Searched: site docs (120 pages; partial)"); + expect(text).toContain( + "Searched: site docs (docs.example.com; 120 pages; partial)", + ); expect(text.match(/120 pages/g)).toHaveLength(1); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 17465131..232a7d11 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -27,7 +27,7 @@ import { import { projectUnifiedSearchPresentation, type UnifiedSearchAction, - type UnifiedSearchAlternativeFacts, + type UnifiedSearchLifecycle, type UnifiedSearchPresentation, type UnifiedSearchSourceEntry, type UnifiedSearchSourceGroup, @@ -93,6 +93,7 @@ export function renderUnifiedSearchPresentationText( } appendPresentationAlternatives(lines, presentation); + appendPresentationSiteSuggestions(lines, presentation); appendPresentationAction(lines, presentation); return lines.join("\n"); } @@ -107,7 +108,7 @@ function formatPresentationOutcome( const countLabel = `${count} result${count === 1 ? "" : "s"}`; if (presentation.lifecycle.kind === "active") { - const label = capitalize(presentation.lifecycleHeadline ?? "active"); + const label = activeLifecycleLabel(presentation.lifecycle); if (presentation.availability.kind === "no_snapshot") { return `${label}${targetSuffix} - no result snapshot returned yet`; } @@ -133,6 +134,19 @@ function formatPresentationOutcome( return `${status} - no results returned`; } +function activeLifecycleLabel( + lifecycle: Extract, +): string { + switch (lifecycle.status) { + case "PENDING": + return "Preparing"; + case "INDEXING": + return "Indexing"; + case "SEARCHING": + return "Searching"; + } +} + function presentationTarget( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], @@ -194,6 +208,13 @@ function appendPresentationSources( { state: "available_not_searched", label: "Available but not searched" }, { state: "unavailable", label: "Unavailable" }, ]; + const contextTargets = new Set( + groups.flatMap((group) => + group.entries.map((entry) => entry.contextTarget ?? entry.target), + ), + ); + const showTargetContext = + contextTargets.size > 1 || groups.some((group) => group.entries.length > 1); for (const { state, label } of states) { const entries = groups.flatMap((group) => group.entries @@ -202,7 +223,13 @@ function appendPresentationSources( ); if (entries.length === 0) continue; const values = entries.map(({ group, entry }) => - formatSourceReadiness(group, entry, state, trustLimits), + formatSourceReadiness( + group, + entry, + state, + trustLimits, + showTargetContext, + ), ); const unique = [...new Set(values)]; lines.push(`${label}: ${unique.join(", ")}`); @@ -214,9 +241,16 @@ function formatSourceReadiness( entry: UnifiedSearchSourceEntry, state: UnifiedSearchSourceEntry["state"], trustLimits: UnifiedSearchTrustLimit[], + showTargetContext: boolean, ): string { const sourceLabel = sourceGroupLabel(group.kind); - if (state === "unavailable") return `${sourceLabel} (${entry.target})`; + const contextTarget = showTargetContext + ? (entry.contextTarget ?? entry.target) + : undefined; + const contextSuffix = contextTarget ? ` for ${contextTarget}` : ""; + if (state === "unavailable") { + return `${sourceLabel} (${entry.target})${contextSuffix}`; + } const coverage = trustLimits.find( (limit): limit is Extract => limit.kind === "coverage" && @@ -225,16 +259,37 @@ function formatSourceReadiness( ); const coverageDetails = coverage ? formatCoverageLimit(coverage) : undefined; if (state === "searched") { - return coverageDetails - ? `${sourceLabel} (${coverageDetails})` - : sourceLabel; + const identity = + group.kind === "code" + ? undefined + : formatDocumentationSourceIdentity(group, entry); + const details = [identity, coverageDetails].filter( + (value): value is string => Boolean(value), + ); + return `${sourceLabel}${details.length > 0 ? ` (${details.join("; ")})` : ""}${contextSuffix}`; + } + if (state === "waiting") { + const identity = + showTargetContext && group.kind !== "code" + ? formatDocumentationSourceIdentity(group, entry) + : undefined; + return `${sourceLabel}${identity ? ` (${identity})` : ""}${contextSuffix}`; } - if (state === "waiting") return sourceLabel; const identity = group.kind === "site_docs" ? `${entry.siteKey ?? entry.siteUrl ?? entry.target} docs` : `${sourceLabel} (${entry.target})`; - return coverageDetails ? `${identity} (${coverageDetails})` : identity; + return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix}`; +} + +function formatDocumentationSourceIdentity( + group: UnifiedSearchSourceGroup, + entry: UnifiedSearchSourceEntry, +): string { + if (group.kind === "repository_docs") { + return `${entry.repositoryUrl ?? entry.target}${entry.commitSha ? ` @ ${entry.commitSha}` : ""}`; + } + return entry.siteKey ?? entry.siteUrl ?? entry.target; } function sourceGroupLabel(kind: UnifiedSearchSourceGroup["kind"]): string { @@ -306,7 +361,7 @@ function appendPresentationAlternatives( lines: string[], presentation: UnifiedSearchPresentation, ): void { - const alternatives = mergePresentationAlternatives(presentation.alternatives); + const alternatives = presentation.alternatives; for (const alternative of alternatives) { const categories: string[] = []; if (alternative.versions.length > 0) { @@ -332,85 +387,26 @@ function appendPresentationAlternatives( } } -interface DisplayAlternativeFacts { - target?: string; - versions: UnifiedSearchPresentation["alternatives"][number]["versions"]; - versionsRemaining: number; - refs: UnifiedSearchPresentation["alternatives"][number]["refs"]; - refsRemaining: number; - suggestedRefs: UnifiedSearchPresentation["alternatives"][number]["suggestedRefs"]; - suggestedRefsRemaining: number; -} - -function mergePresentationAlternatives( - alternatives: UnifiedSearchPresentation["alternatives"], -): DisplayAlternativeFacts[] { - const merged: DisplayAlternativeFacts[] = []; - for (const alternative of alternatives) { - const key = alternative.target - ? alternative.target.replace(/@[^/@]+$/, "") - : ""; - let display = merged.find( - (candidate) => - (candidate.target ? candidate.target.replace(/@[^/@]+$/, "") : "") === - key, - ); - if (!display) { - display = { - target: alternative.target, - versions: [], - versionsRemaining: 0, - refs: [], - refsRemaining: 0, - suggestedRefs: [], - suggestedRefsRemaining: 0, - }; - merged.push(display); - } - appendBoundedAlternatives( - display.versions, - alternative.versions, - (remaining) => (display.versionsRemaining += remaining), - ); - display.versionsRemaining = Math.max( - display.versionsRemaining, - alternative.versionsRemaining, - ); - appendBoundedAlternatives( - display.refs, - alternative.refs, - (remaining) => (display.refsRemaining += remaining), - ); - display.refsRemaining = Math.max( - display.refsRemaining, - alternative.refsRemaining, - ); - appendBoundedAlternatives( - display.suggestedRefs, - alternative.suggestedRefs, - (remaining) => (display.suggestedRefsRemaining += remaining), - ); - display.suggestedRefsRemaining = Math.max( - display.suggestedRefsRemaining, - alternative.suggestedRefsRemaining, - ); - } - return merged; -} - -function appendBoundedAlternatives( - target: UnifiedSearchAlternativeFacts["versions"], - values: UnifiedSearchAlternativeFacts["versions"], - addRemaining: (remaining: number) => void, +function appendPresentationSiteSuggestions( + lines: string[], + presentation: UnifiedSearchPresentation, ): void { - for (const value of values) { - const duplicate = target.some( - (candidate) => - candidate.version === value.version && candidate.ref === value.ref, + const seen = new Set(); + for (const facts of presentation.siteSuggestions) { + const suggestions = facts.suggestions.filter((suggestion) => { + if (seen.has(suggestion)) return false; + seen.add(suggestion); + return true; + }); + if (suggestions.length === 0) continue; + const targetSuffix = + presentation.siteSuggestions.length > 1 ? ` for ${facts.target}` : ""; + lines.push( + `Suggested site targets${targetSuffix}: ${suggestions.join(", ")}`, ); - if (duplicate) continue; - if (target.length < 3) target.push(value); - else addRemaining(1); + } + if (presentation.siteSuggestions.some((facts) => facts.truncated)) { + lines.push("Additional site targets were omitted."); } } @@ -465,6 +461,16 @@ function appendPresentationAction( ); return; } + if (action.kind === "site_retry") { + lines.push( + presentation.lifecycle.kind === "terminal" || + presentation.lifecycle.kind === "unknown" + ? "Do not call search_status again for this session." + : "Do not repeat immediately.", + ); + lines.push("Next: retry one suggested site target explicitly."); + return; + } if (action.kind === "query_rewrite") { lines.push( hasEvidenceLimit(presentation.trustLimits) diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index 19a55793..a2eb60ae 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -521,7 +521,7 @@ describe("searchStatusTool", () => { }, ); - it("renders the site outcome without stale recovery prose", async () => { + it("renders site suggestions without selecting one during active recovery", async () => { if (defaultUnifiedSearchOutcome.state !== "completed") { throw new Error("expected completed outcome fixture"); } @@ -556,11 +556,13 @@ describe("searchStatusTool", () => { expect(text).toContain( "Indexing site:example.com - no results returned yet", ); - expect(text).toContain("Searched: site docs"); + expect(text).toContain("Searched: site docs (site:example.com)"); + expect(text).toContain("Suggested site targets: site:docs.example.com"); + expect(text).toContain("Additional site targets were omitted."); expect(text).toContain( 'Next: search_status search_ref="ref-site-recovery" wait_timeout_ms=20000', ); - expect(text).not.toContain("Suggested site targets"); + expect(text).not.toContain("Next: retry one suggested site target"); }); it("surfaces progress freshness warnings", async () => { @@ -665,7 +667,7 @@ describe("searchStatusTool", () => { expect(text).not.toContain("ref_resolution_deferred"); }); - it("renders completed site emptiness without stale recovery prose", async () => { + it("renders completed site suggestions as explicit recovery guidance", async () => { if (defaultUnifiedSearchOutcome.state !== "completed") { throw new Error("expected completed outcome fixture"); } @@ -702,8 +704,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; expect(text).toContain("No results returned from site:example.com"); - expect(text).toContain("Next: shorten or broaden site query."); - expect(text).not.toContain("Suggested site targets"); + expect(text).toContain("Searched: site docs (site:example.com)"); + expect(text).toContain("Suggested site targets: site:example.com/docs"); + expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain("Next: retry one suggested site target explicitly."); + expect(text).not.toContain("Next: shorten or broaden site query."); }); it("renders terminal source status compactly in completed text", async () => { From 83587179b14ea18412a7b453009a9d074fad57e6 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 08:14:49 +0300 Subject: [PATCH 10/46] docs: document outcome-first search contract Record the in-place text-v1 evolution policy, MCP search lifecycle and action hierarchy, and additive partial-result JSON parity. Add the Phase 1a patch fragment for both public artifacts while keeping CLI human output documented as unchanged. --- changes/search-output-hierarchy.changed.md | 6 ++++ docs/implementation/cli-commands.md | 4 +-- docs/implementation/mcp-cli-parity.md | 33 +++++++++++++++++++ docs/implementation/tools.md | 26 ++++++++------- .../mcp/src/shared/unified-search-text.ts | 7 ++-- 5 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 changes/search-output-hierarchy.changed.md diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md new file mode 100644 index 00000000..402a4856 --- /dev/null +++ b/changes/search-output-hierarchy.changed.md @@ -0,0 +1,6 @@ +--- +"githits": patch +"@githits/mcp": patch +--- + +- **Clarify unified search output** - Add exact partial-result truth to JSON and make MCP search and search-status text outcome-first with concise lifecycle, readiness, provenance, and continuation guidance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 97feb267..2f631215 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -232,11 +232,11 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. -**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. Callers follow an explicit rendered `search-status` action rather than repeating `search`; ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth; it does not change the current CLI human terminal rendering. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. Callers follow an explicit rendered `search-status` action rather than repeating `search`; ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** Plain output preserves backend ranking order. It starts with a lightweight per-type count summary, then shows one result per block. The header line is optimized for scanning and copy-paste follow-up: `target path:range [type] - title`. For file-backed hits, that header can be turned directly into a `githits code read` call because `code read` accepts `path:start-end` suffixes. Summaries are rendered verbatim from the backend response. Labels are: `docs page` (hosted package docs), `repo doc` (documentation-like block from a repository file), `repo code` (code block from a repository file), and `repo symbol` (explicit symbol hit from the repository index). `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches. +**Output.** Plain output preserves backend ranking order. It starts with a lightweight per-type count summary, then shows one result per block. The CLI human renderer remains the current terminal format in Phase 1a; the outcome-first migration applies to MCP text-v1, with CLI human output planned for Phase 1b. The header line is optimized for scanning and copy-paste follow-up: `target path:range [type] - title`. For file-backed hits, that header can be turned directly into a `githits code read` call because `code read` accepts `path:start-end` suffixes. Summaries are rendered verbatim from the backend response. Labels are: `docs page` (hosted package docs), `repo doc` (documentation-like block from a repository file), `repo code` (code block from a repository file), and `repo symbol` (explicit symbol hit from the repository index). `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. **Highlighting.** The CLI applies the backend's structured `highlights` spans on titles and summaries, plus structural emphasis on headers and badges. It does **not** attempt client-side substring highlighting for terms the backend did not flag, since the compiled query is not a faithful match spec. diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 6d44c4f1..bd9d47bb 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -230,6 +230,39 @@ test suite anchors the doc. healthy source status remains suppressed for non-empty success. Text advice is renderer-only and never replaces structured JSON. +### Search output parity + +Phase 1a changes MCP `search` and `search_status` default `text-v1` in place. +The MCP text contract is outcome-first: one outcome line, one concise readiness +and trust summary, result blocks, bounded alternatives or provenance, and one +action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct; active +no-snapshot output says that no result snapshot was returned, while active empty +output says that no results were returned yet. Active result counts use +`interim` when `partialResults` is false and `partial` when it is true. +Terminal and unknown statuses retain their exact status and never poll the same +reference. Site suggestions remain ordered advisory labels with explicit retry +guidance; they are never selected automatically. Parser/query and structured +constraint facts appear once below the outcome, while promoted lifecycle warning +prose and opaque evidence text stay out of default MCP text. + +The three MCP anti-repeat directives are part of this text behavior: +`Do not repeat search.` for active polling, `Do not repeat this search unchanged.` +for an ordinary completed empty result, and `Do not repeat immediately.` for +evidence-limited or status-continuation actions. A rendered `searchRef` appears +only in the exact `Next: search_status search_ref=... wait_timeout_ms=...` +action; terminal and unknown responses instead give an explicit no-poll +instruction. + +The CLI human renderer is unchanged in Phase 1a. Its `--json` output and MCP +`format: "json"` output remain the structured parity boundary: every +result-bearing initial payload and stored `search_status.result` carries the +backend's exact `partialResults: boolean`, including both `false` and `true`; +payloads with no result snapshot omit that field. Full `warnings[]`, source +diagnostics, evidence notices, reason codes, references, and alternative lists +remain available in JSON even when MCP text classifies or bounds them for +readability. The shared JSON parity tests compare these envelopes deeply; only +surface-native text and follow-up syntax differ. + ### `PARITY-ERROR-ENVELOPE` - Every error result, on both surfaces, carries diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index a6ce9c75..eb6f0ea9 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -105,13 +105,15 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Unified `search` query syntax.** The `search.query` field is the backend discovery query syntax, not a raw pass-through to a per-source search engine. It supports implicit `AND`, uppercase `OR`, parentheses, unary `-`, quoted phrases, semantic qualifiers (`kind:`, `category:`, `path:`, `lang:`, `name:`, `intent:`), and routing qualifiers (`registry:`, `package:`, `version:`, `repo:`). The backend parses the query once and compiles it per source. Structured `name` and `language` inputs are compiled into `name:` / `lang:` qualifiers and AND-ed with the query before sending. Per-source support, ignored features, and incompatibilities are reported in `sourceStatus`. -**Promoted `warnings[]`.** Noteworthy `sourceStatus` entries — sources reporting `incompatibleQueryFeatures`, `ignoredQueryFeatures`, `incompatibleFilters`, `ignoredFilters`, lifecycle anomalies (`indexingStatus`, `codeIndexState`), or a free-form `note` — are also surfaced as a top-level `warnings: string[]` in the completed/incomplete payloads (and appended after parser warnings inside the `search_status` result block). The structured detail still lives in `sourceStatus`; `warnings[]` is the agent-visible signal that something about execution did not match the request. On completed empty results, healthy non-contributor source entries are also retained with zero `resultCount` and served identity; requested/fresh labels emit only when they materially differ from served. Contributor-bearing DOCS rows retain their physical contributors instead of duplicating healthy served/current resolution metadata. Healthy `INDEXED` / `CURRENT` / non-divergent `STALE` states never become warnings. `PROVISIONAL` is queryable but remains a visible non-healthy indexing signal, including on completed responses. Successful non-empty responses keep the prior compact projection. The text-v1 renderer prints backend warnings and source notes before empty-result advice. Implementation in `buildSourceStatusWarnings` and empty-result compaction (`packages/mcp/src/shared/unified-search-response.ts`). +**Partial-result truth.** Every result-bearing initial `search` payload and stored `search_status.result` carries the backend's exact `partialResults: boolean`, including `false` for an atomic serveable interim snapshot and `true` for a subset of requested evidence. A progress-only response with no result snapshot omits the field. This additive field is retained unchanged in CLI `--json` and MCP `format: "json"`; text-v1 uses it only to label active results as interim or partial. -**Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases; neither `search` nor `search_status` rewrites or retries the target automatically. Active admitted-site crawls and repairs participate in ordinary discovery sessions: incomplete responses carry a `searchRef` and progress, while stale-but-serveable evidence can remain available during refresh. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. +**Promoted `warnings[]`.** Noteworthy `sourceStatus` entries — sources reporting `incompatibleQueryFeatures`, `ignoredQueryFeatures`, `incompatibleFilters`, `ignoredFilters`, lifecycle anomalies (`indexingStatus`, `codeIndexState`), or a free-form `note` — are also surfaced as a top-level `warnings: string[]` in the completed/incomplete payloads (and appended after parser warnings inside the `search_status` result block). The structured detail still lives in `sourceStatus`; `warnings[]` is the agent-visible signal that something about execution did not match the request. On completed empty results, healthy non-contributor source entries are also retained with zero `resultCount` and served identity; requested/fresh labels emit only when they materially differ from served. Contributor-bearing DOCS rows retain their physical contributors instead of duplicating healthy served/current resolution metadata. Healthy `INDEXED` / `CURRENT` / non-divergent `STALE` states never become warnings. `PROVISIONAL` is queryable but remains a visible non-healthy indexing signal, including on completed responses. Successful non-empty responses keep the prior compact projection. JSON keeps promoted warnings and source-status detail lossless; MCP text classifies parser/query and structured constraint facts once below the outcome and does not repeat promoted lifecycle/freshness warning prose or opaque notes. Implementation in `buildSourceStatusWarnings` and empty-result compaction (`packages/mcp/src/shared/unified-search-response.ts`). -**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` even when otherwise healthy. Repository contributors expose normalized `repositoryUrl`, full `commitSha`, freshness, and current-page `resultCount`; docpacks expose stable `siteKey`, canonical `siteUrl`, and selected published coverage. The JSON projection preserves meaningful zero/null values and every selected docpack coverage field, but omits duplicate pair-level count/coverage and incidental healthy resolution metadata. Text-v1 optimizes for interpretation instead of mirroring that structure: directly below the result count and before the hits, fully current searched sources collapse to `searched: site ...; repo ... @ `, with no repeated hit counts or coverage totals. Docpack labels come only from contributor `siteUrl`, retain a canonical path scope, and are therefore identical for empty and non-empty result pages; returned hit URLs are never used to infer which site was searched. The target label is omitted for one target and retained only when multiple targets need disambiguation. Stale, provisional, partial, capped, ready-but-unused, pending, unavailable, and coverage-undisclosed sources instead expand into a `documentation sources` block in the same pre-result position, explaining the exceptional state and whether the source was searched. A `SEARCHED` contributor with `PROVISIONAL` freshness explicitly says that a provisional index was searched while indexing continues. In mixed blocks, healthy contributors explicitly say `searched`; a searched docpack without coverage says that published coverage details are unavailable. When any disclosed contributor was not searched, an empty headline scopes the claim to searched evidence. Pending-evidence notices suppress query pivots; otherwise the ordinary empty-result guidance still applies to the evidence that was searched. If `siteUrl` is absent, text uses the generic `site documentation` identity; site sources are numbered only when their displayed identities collide. JSON remains the exact source for stable keys, canonical URLs, and all coverage fields. Partial/capped coverage is published evidence, not a progress or retry signal. +**Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. -`evidenceNotice` is carried once on initial and stored result envelopes and rendered once in text. A `searchRef` is actionable only when rendered output supplies a `search_status` follow-up. Ordinary cases are known active progress (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result carrying an evidence notice. Terminal `DEFERRED` keeps `completed: false`, exact progress, and any stored result, but its `searchRef` no longer advances: callers use the disclosed evidence now and issue a new search later for a fresher snapshot. Terminal `DEFERRED`, `TIMEOUT`, and `FAILED` output never directs callers back to the same session. Session status is an open backend-owned string so adding an enum value does not invalidate the response. An unrecognized value is preserved in JSON and text with any disclosed evidence, but the client does not guess whether it is active or terminal and does not poll the same reference; it directs a later new search instead. Without a reference, the notice is the only retry-variability guidance. `search_status(includeResults: true)` uses the same result projection and formatter—contributors are never copied onto generic progress targets, and `allowPartialResults` retains its separate pair-omission meaning. +**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` even when otherwise healthy. Repository contributors expose normalized `repositoryUrl`, full `commitSha`, freshness, and current-page `resultCount`; docpacks expose stable `siteKey`, canonical `siteUrl`, and selected published coverage. The JSON projection preserves meaningful zero/null values and every selected docpack coverage field, but omits duplicate pair-level count/coverage and incidental healthy resolution metadata. MCP text-v1 uses one outcome-first readiness block: fully current searched sources collapse to `Searched: site ...; repository docs ... @ `, while waiting, available-but-unsearched, unavailable, stale, provisional, partial, capped, or coverage-undisclosed sources retain the one concise state needed to interpret the result. Docpack labels come only from contributor `siteUrl`, retain a canonical path scope, and are therefore identical for empty and non-empty result pages; returned hit URLs are never used to infer which site was searched. Target context is omitted for one target and retained only when multiple targets need disambiguation. A `SEARCHED` contributor with `PROVISIONAL` freshness explicitly says that a provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline scopes the claim to searched evidence. If `siteUrl` is absent, text uses the generic `site documentation` identity; site sources are numbered only when their displayed identities collide. JSON remains the exact source for stable keys, canonical URLs, and all coverage fields. Partial/capped coverage is published evidence, not a progress or retry signal. + +`evidenceNotice` is carried once on initial and stored result envelopes. MCP text summarizes its presence once as `Evidence may change.` rather than copying opaque backend prose; JSON retains the exact notice. A `searchRef` is actionable only when rendered output supplies a `search_status` follow-up. Ordinary cases are known active progress (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result carrying an evidence notice. Terminal `DEFERRED` keeps `completed: false`, exact progress, and any stored result, but its `searchRef` no longer advances: callers use the disclosed evidence now and issue a new search later for a fresher snapshot. Terminal `DEFERRED`, `TIMEOUT`, and `FAILED` output never directs callers back to the same session. Session status is an open backend-owned string so adding an enum value does not invalidate the response. An unrecognized value is preserved in JSON and text with any disclosed evidence, but the client does not guess whether it is active or terminal and does not poll the same reference; it directs a later new search instead. Without a reference, the notice is the only retry-variability guidance. `search_status(includeResults: true)` uses the same result projection and formatter—contributors are never copied onto generic progress targets, and `allowPartialResults` retains its separate pair-omission meaning. ### `pkg_info` response shape @@ -199,7 +201,7 @@ These three indexed tools share an addressing and lifecycle contract (documented **`code_grep` envelope**: `{registry?|name?|repoUrl?+gitRef?, pattern, patternType?, caseSensitive?, matches: [{filePath, line, matchStartByte, matchEndByte, lineContent, contextBefore?, contextAfter?, fileContentHash?, fileIntent?, symbol?}], nextCursor?, hasMore, truncatedReason?, filesScanned, filesInScope, binaryFilesSkipped?, filesTooLargeSkipped?, totalMatches, uniqueFilesMatched, indexedVersion?, resolution?, targetResolution?, filter?}`. Default-valued fields (`patternType: literal`, `caseSensitive: false`, zero skipped counters, `truncatedReason: none`) are omitted. `filter` echoes only explicit caller filters. Match entries carry `filePath` so grep output chains directly into `code_read`. -`targetResolution` is additive provenance. It explains requested, resolved-requested, and served artifacts plus `freshness` (`current`, `fallback_recent`, `indexing`, `provisional`, or `unavailable`), `freshnessReason`, `indexingRef`, `availableVersions`, `availableRefs`, and `suggestedRefs`. A `provisional` / `exact_provisional` Discovery result is queryable while indexing continues; text uses the exact served identity and `indexingRef` and does not substitute a requested ref. `availableVersions` and `availableRefs` are already-indexed artifacts that can be queried immediately. `suggestedRefs` are fuzzy upstream candidates and may require indexing before use. Existing `indexedVersion`, `resolution`, and locator fields remain served-identity compatibility fields. Text mode renders actionable notes such as `Using recent indexed snapshot`, `Serving an older indexed snapshot; current target is still being indexed`, `Requested ref is being indexed`, `provisional (still indexing)`, `Fresh target is being indexed`, `Target unavailable`, `queryable now`, or `suggested refs`; JSON mode carries the structured object. A `current` resolution is authoritative on every code-navigation surface and suppresses alternative-target remediation; waited search completion is one case where earlier candidates can remain in structured provenance without becoming warnings. +`targetResolution` is additive provenance. It explains requested, resolved-requested, and served artifacts plus `freshness` (`current`, `fallback_recent`, `indexing`, `provisional`, or `unavailable`), `freshnessReason`, `indexingRef`, `availableVersions`, `availableRefs`, and `suggestedRefs`. A `provisional` / `exact_provisional` Discovery result is queryable while indexing continues; code-navigation text uses the exact served identity and `indexingRef` and does not substitute a requested ref. Unified search text-v1 instead keeps internal `indexingRef` and reason codes out of default text while retaining the user-meaningful served identity and bounded alternatives. `availableVersions` and `availableRefs` are already-indexed artifacts that can be queried immediately. `suggestedRefs` are fuzzy upstream candidates and may require indexing before use. Existing `indexedVersion`, `resolution`, and locator fields remain served-identity compatibility fields. Text mode renders actionable notes such as `Using recent indexed snapshot`, `Serving an older indexed snapshot; current target is still being indexed`, `Requested ref is being indexed`, `provisional (still indexing)`, `Fresh target is being indexed`, `Target unavailable`, `queryable now`, or `suggested refs`; JSON mode carries the structured object. A `current` resolution is authoritative on every code-navigation surface and suppresses alternative-target remediation; waited search completion is one case where earlier candidates can remain in structured provenance without becoming warnings. ### Indexing lifecycle (shared across `code_files`, `code_read`, `code_grep`) @@ -245,7 +247,7 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Why text-v1 default.** A 10-hit `search` JSON envelope runs 5–7 KB after compaction; the same hits in `text-v1` land around 3–4 KB. The savings come from dropped quoting, dropped key repetition, and dropped fields that an agent does not need at the per-call decision point (highlights byte offsets, repeated locator scaffolding). The token budget belongs to the agent's reasoning, not to JSON structure. -**Format stability.** The text format is a public contract, locked with snapshot-style tests (`packages/mcp/src/shared/unified-search-text.test.ts`, `packages/mcp/src/tools/search-status.test.ts`, `packages/mcp/src/shared/list-files-text.test.ts`, `packages/mcp/src/shared/grep-repo-text.test.ts`). The `text-v1` version tag exists so incompatible evolution can ship as `text-v2`. +**In-place evolution.** `text-v1` names the compact line-oriented representation; it is not an exact-prose compatibility boundary. Search and `search_status` may tighten human/agent copy in place as long as their structural lifecycle, ordering, action, and hit-anatomy invariants remain covered by tests (`packages/mcp/src/shared/unified-search-text.test.ts`, `packages/mcp/src/tools/search-status.test.ts`). JSON is the stable structured boundary for programmatic callers. Other text-v1 renderers retain their own contracts and are not changed by the search presentation work. **ASCII-only.** Separators are ` | `; ellipsis is `...`; no box-drawing or Latin-1 punctuation. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — ASCII keeps it predictable. @@ -253,12 +255,14 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, and `pkg_upgrade_review` text mode reuse the shared no-color terminal formatters but inject MCP-native hints. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass verbose=true`, `body_lines=`, or `format="json"` when text omitted lines. Package tools keep JSON errors in all formats because agents can reliably branch on `{error, code, retryable, details?}`. -**Hit anatomy** (`search` text-v1): +**Unified search outcome-first anatomy** (`search` and `search_status` text-v1). The first nonblank line is one outcome: `Preparing`, `Indexing`, or `Searching` for active `PENDING`, `INDEXING`, or `SEARCHING`; a completed result/empty count; or the exact `DEFERRED`, `TIMEOUT`, `FAILED`, or unknown status. A no-snapshot active response says `no result snapshot returned yet`; an active empty snapshot says `no results returned yet`; active hits are labelled `interim` when `partialResults` is false and `partial` when it is true. The remainder is ordered as one readiness/trust summary, query or structured constraint warnings, result blocks, bounded alternatives/provenance and site suggestions, then one action. Progress-only responses show only derivable target readiness and alternatives; they never synthesize source or contributor facts. + +Active output keeps `Do not repeat search.` immediately before one exact action such as `Next: search_status search_ref="..." wait_timeout_ms=20000`. Ordinary completed empty output keeps `Do not repeat this search unchanged.`; evidence-limited output uses `Do not repeat immediately.`; terminal and unknown statuses prohibit polling the stopped or unrecognized reference. Suggested site targets retain backend order and an omitted-candidates signal, but are advisory labels rather than automatic retries. Unified search text prints `searchRef` only inside its exact `Next:` action. + +**Hit anatomy within unified search text-v1:** ``` -search | hits | query="..." -[blank] -[1] +[1] @@ -273,7 +277,7 @@ More hits available. Pass offset=N for the next page or limit=N to widen. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. -Completed empty search renders backend warnings/source notes first, then served target/freshness context and `Do not repeat this search unchanged.` Generic pivots are conditional: filter removal appears only when filters exist, symbol search is omitted when already selected, and standalone site searches do not suggest `code_grep`. If the completed source is still indexing, including `PROVISIONAL`, query rewriting is suppressed in favor of a larger `wait_timeout_ms` or an indexed alternative labelled `queryable now`. Active incomplete search reports ready/total counts, says `Do not repeat search.`, and gives the exact bounded continuation `next: call search_status with search_ref="..." and wait_timeout_ms=20000.` A completed result with both an evidence notice and `searchRef` emits the same explicit continuation. Terminal `DEFERRED` preserves available evidence, explains that lifecycle work continues outside the stopped session, prohibits further status calls, and directs a later new `search`; it never claims that no hits exist or that the session is indexing. Terminal `FAILED` and `TIMEOUT` sessions likewise prohibit further status calls and direct the caller to rerun `search` instead. The response never suggests changing `allow_partial_results` after an incomplete response. +Completed empty search uses the model's applicable action: generic query pivots are suppressed for evidence-limited or unsearched sources, indexing/provisional evidence prefers waiting or an indexed alternative, standalone site searches expose only a shorter/broader site query, and filter removal or symbol/code-grep pivots appear only when applicable. A completed result with both an evidence notice and `searchRef` emits one status continuation after the generic `Evidence may change.` trust statement. Terminal `DEFERRED`, `FAILED`, and `TIMEOUT` preserve disclosed evidence but prohibit further status calls; unknown statuses preserve the raw value and use the same conservative no-polling boundary. Promoted lifecycle/freshness warning prose and opaque evidence text remain in JSON but are not repeated in default MCP text; parser/query and structured constraint facts appear once below the outcome. **Listing anatomy** (`code_files` text-v1): diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 232a7d11..aa2bceea 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -10,9 +10,10 @@ * variants, and there are no Unicode characters that require * client-side escaping. * - * Format is a public contract — locked with snapshot-style tests in - * `unified-search-text.test.ts`. Update the spec in - * `docs/implementation/tools.md` when changing the format. + * `text-v1` names the compact representation, not an exact-prose compatibility + * boundary. Its lifecycle, ordering, action, and hit-anatomy invariants are + * covered by structural tests in `unified-search-text.test.ts`; JSON remains + * the stable structured boundary for programmatic callers. */ import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; From 963902a72f8d06dacaff7c73dfe605261c2dc568 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 08:31:56 +0300 Subject: [PATCH 11/46] fix: bound unified search summaries Route source readiness, indexed alternatives, and site recovery facts through the existing bounded text wrapper while preserving exact identifiers. Make the shared presentation projection authoritative for pagination so initial and stored status renderers cannot disagree about more results. --- .../src/shared/unified-search-status-text.ts | 1 - .../src/shared/unified-search-text.test.ts | 90 +++++++++++++++++-- .../mcp/src/shared/unified-search-text.ts | 21 +++-- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-status-text.ts b/packages/mcp/src/shared/unified-search-status-text.ts index 9947f778..1d56bb9f 100644 --- a/packages/mcp/src/shared/unified-search-status-text.ts +++ b/packages/mcp/src/shared/unified-search-status-text.ts @@ -14,7 +14,6 @@ export function renderUnifiedSearchStatusText(payload: StatusPayload): string { const result = payload.result; return renderUnifiedSearchPresentationText(presentation, { results: result?.results ?? [], - hasMore: result?.hasMore ?? false, nextOffset: result?.nextOffset, }); } diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 88153158..662df9c0 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { projectUnifiedSearchPresentation } from "./unified-search-presentation.js"; import type { UnifiedSearchCompletedPayload, UnifiedSearchErrorPayload, @@ -8,6 +9,7 @@ import type { } from "./unified-search-response.js"; import { renderUnifiedSearchError, + renderUnifiedSearchPresentationText, renderUnifiedSearchSuccess, } from "./unified-search-text.js"; @@ -225,7 +227,7 @@ describe("renderUnifiedSearchSuccess", () => { "Available but not searched: n8n.io docs (1,480 pages; capped)", ); expect(text).toContain( - "Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2 more; refs HEAD, master", + "Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2 more; refs HEAD,\nmaster", ); expect(text).toContain( 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', @@ -398,14 +400,18 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain( "Waiting: code for npm:one@1.0.0, code for npm:two@2.0.0", ); - expect(text).toContain( - "repository docs (https://github.com/one/repo @ commit-one) for npm:one@1.0.0", + expect(text).toMatch( + /repository docs \(https:\/\/github\.com\/one\/repo @ commit-one\) for\nnpm:one@1\.0\.0/, ); - expect(text).toContain( - "repository docs (https://github.com/two/repo @ commit-two) for npm:two@2.0.0", + expect(text).toMatch( + /repository docs \(https:\/\/github\.com\/two\/repo @ commit-two\)\nfor npm:two@2\.0\.0/, + ); + expect(text).toMatch( + /site docs \(docs\.one\.example\) for npm:one@1\.0\.0/, + ); + expect(text).toMatch( + /site docs\n\(docs\.two\.example\) for npm:two@2\.0\.0/, ); - expect(text).toContain("site docs (docs.one.example) for npm:one@1.0.0"); - expect(text).toContain("site docs (docs.two.example) for npm:two@2.0.0"); }); it.each([ @@ -671,13 +677,81 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("[1] cline/cline@v3.4.2"); expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); expect(text).toContain( - "Indexed alternatives: versions 5.2.1, 5.2.0, 5.1.0 +1 more; refs HEAD, main, next +1 more", + "Indexed alternatives: versions 5.2.1, 5.2.0, 5.1.0 +1 more; refs HEAD, main,\nnext +1 more", ); expect(text).toContain("More hits available. Pass offset=10"); expect(text).not.toContain("v5.0.0"); expect(text).not.toContain("dev"); }); + it("uses the presentation pagination flag as the rendering authority", () => { + const payload = completed([], { hasMore: true, nextOffset: 10 }); + const presentation = projectUnifiedSearchPresentation(payload); + const text = renderUnifiedSearchPresentationText(presentation, { + results: payload.results, + nextOffset: payload.nextOffset, + }); + + expect(presentation.hasMore).toBe(true); + expect(text).toContain("More hits available. Pass offset=10"); + }); + + it("wraps bounded summaries without splitting exact tokens", () => { + const targetOne = "npm:one-long-package@1.0.0"; + const targetTwo = "npm:two-long-package@2.0.0"; + const suggestions = [ + "site:docs.example.com/guide/one", + "site:docs.example.com/guide/two", + "site:docs.example.com/guide/three", + ]; + const longRef = `refs/${"x".repeat(90)}`; + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: targetOne, + codeIndexState: "INDEXING", + targetResolution: { + availableVersions: [{ version: "1.0.0", ref: "v1.0.0" }], + availableRefs: [{ ref: longRef }], + }, + }), + source({ + targetLabel: targetTwo, + codeIndexState: "INDEXING", + targetResolution: { + availableVersions: [{ version: "2.0.0", ref: "v2.0.0" }], + availableRefs: [{ ref: "main" }], + }, + }), + source({ + source: "docs", + targetLabel: "site:docs.example.com", + suggestedSiteTargets: suggestions, + suggestedSiteTargetsTruncated: true, + }), + ], + }), + ); + + const lines = text.split("\n"); + const summaryLines = lines.filter((line) => + /^(Waiting|Searched|Indexed alternatives|Suggested site targets)/.test( + line, + ), + ); + expect(summaryLines.length).toBeGreaterThan(3); + expect(summaryLines.every((line) => line.length <= 76)).toBe(true); + expect(text).toContain(targetOne); + expect(text).toContain(targetTwo); + expect(text).toContain(longRef); + expect(text).toContain("Additional site targets were omitted."); + + const overlongLines = lines.filter((line) => line.length > 76); + expect(overlongLines).toHaveLength(1); + expect(overlongLines[0]).toContain(longRef); + }); + it("shows capped searched coverage without repeating the trust limit", () => { const text = renderUnifiedSearchSuccess( completed([], { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index aa2bceea..15e97d5b 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -65,7 +65,6 @@ export function renderUnifiedSearchSuccess( export interface UnifiedSearchTextResult { results: UnifiedSearchHitPayload[]; - hasMore: boolean; nextOffset?: number; } @@ -84,7 +83,7 @@ export function renderUnifiedSearchPresentationText( appendUnifiedSearchHits(lines, result.results); } - if (result.hasMore) { + if (presentation.hasMore) { if (lines[lines.length - 1] !== "") lines.push(""); const nextOffsetHint = typeof result.nextOffset === "number" @@ -233,7 +232,7 @@ function appendPresentationSources( ), ); const unique = [...new Set(values)]; - lines.push(`${label}: ${unique.join(", ")}`); + lines.push(...wrapText(`${label}: ${unique.join(", ")}`)); } } @@ -362,8 +361,7 @@ function appendPresentationAlternatives( lines: string[], presentation: UnifiedSearchPresentation, ): void { - const alternatives = presentation.alternatives; - for (const alternative of alternatives) { + for (const alternative of presentation.alternatives) { const categories: string[] = []; if (alternative.versions.length > 0) { categories.push( @@ -382,7 +380,9 @@ function appendPresentationAlternatives( } if (categories.length > 0) { lines.push( - `Indexed alternatives${alternatives.length > 1 && alternative.target ? ` for ${alternative.target}` : ""}: ${categories.join("; ")}`, + ...wrapText( + `Indexed alternatives${presentation.alternatives.length > 1 && alternative.target ? ` for ${alternative.target}` : ""}: ${categories.join("; ")}`, + ), ); } } @@ -403,7 +403,9 @@ function appendPresentationSiteSuggestions( const targetSuffix = presentation.siteSuggestions.length > 1 ? ` for ${facts.target}` : ""; lines.push( - `Suggested site targets${targetSuffix}: ${suggestions.join(", ")}`, + ...wrapText( + `Suggested site targets${targetSuffix}: ${suggestions.join(", ")}`, + ), ); } if (presentation.siteSuggestions.some((facts) => facts.truncated)) { @@ -1396,7 +1398,7 @@ function formatDetailValue(value: unknown): string { return JSON.stringify(value); } -function wrapText(text: string, width: number): string[] { +function wrapText(text: string, width = SUMMARY_WRAP_WIDTH): string[] { const lines: string[] = []; for (const paragraph of text.split(/\n/)) { if (paragraph.length === 0) { @@ -1406,7 +1408,8 @@ function wrapText(text: string, width: number): string[] { let remaining = paragraph.trim(); while (remaining.length > width) { let breakAt = remaining.lastIndexOf(" ", width); - if (breakAt <= 0) breakAt = width; + if (breakAt <= 0) breakAt = remaining.indexOf(" ", width); + if (breakAt < 0) breakAt = remaining.length; lines.push(remaining.slice(0, breakAt).trimEnd()); remaining = remaining.slice(breakAt).trimStart(); } From 982cc789b7b6684358207135e633dea936d4c631 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 08:42:59 +0300 Subject: [PATCH 12/46] fix: retain unified search query and site context Preserve parser warnings from initial progress-only queries and use canonical site URL paths before site keys in MCP text. Align permanent documentation with the current in-place text contract and MCP source identity fallback. --- docs/implementation/cli-commands.md | 4 +- docs/implementation/mcp-cli-parity.md | 4 +- docs/implementation/tools.md | 2 +- .../unified-search-presentation.test.ts | 17 +++++++ .../src/shared/unified-search-presentation.ts | 13 ++--- .../src/shared/unified-search-text.test.ts | 49 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 5 +- 7 files changed, 78 insertions(+), 16 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 2f631215..0d2823d7 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -236,11 +236,11 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** Plain output preserves backend ranking order. It starts with a lightweight per-type count summary, then shows one result per block. The CLI human renderer remains the current terminal format in Phase 1a; the outcome-first migration applies to MCP text-v1, with CLI human output planned for Phase 1b. The header line is optimized for scanning and copy-paste follow-up: `target path:range [type] - title`. For file-backed hits, that header can be turned directly into a `githits code read` call because `code read` accepts `path:start-end` suffixes. Summaries are rendered verbatim from the backend response. Labels are: `docs page` (hosted package docs), `repo doc` (documentation-like block from a repository file), `repo code` (code block from a repository file), and `repo symbol` (explicit symbol hit from the repository index). `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** Plain output preserves backend ranking order. It starts with a lightweight per-type count summary, then shows one result per block. The CLI human renderer remains the current terminal format; the outcome-first migration applies to MCP text-v1. The header line is optimized for scanning and copy-paste follow-up: `target path:range [type] - title`. For file-backed hits, that header can be turned directly into a `githits code read` call because `code read` accepts `path:start-end` suffixes. Summaries are rendered verbatim from the backend response. Labels are: `docs page` (hosted package docs), `repo doc` (documentation-like block from a repository file), `repo code` (code block from a repository file), and `repo symbol` (explicit symbol hit from the repository index). `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. **Highlighting.** The CLI applies the backend's structured `highlights` spans on titles and summaries, plus structural emphasis on headers and badges. It does **not** attempt client-side substring highlighting for terms the backend did not flag, since the compiled query is not a faithful match spec. -**Trust signals.** The JSON `sourceStatus` block is included when a source reports an actionable condition or a DOCS row discloses physical `contributors`. It retains each source's state, freshness, current-page `resultCount`, and exact repository or docpack metadata. Human output is intentionally smaller. Directly below the result count, current, searched, fully published sources collapse to `Searched: site ...; repo ... @ `. A single target needs no package label; multi-target searches retain labels only to disambiguate each source set. The summary does not repeat freshness, page coverage, or hit counts already conveyed by the result count. If a source is stale, provisional, partial, capped, not ready, unavailable, or missing published coverage details, a **Documentation sources** block appears before the hits and explains that exception in plain language, including whether each source was searched. A searched provisional contributor explicitly says that the provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline says only that the searched evidence returned no hits. Without a pending-evidence notice, CLI output then suggests one applicable next step: a larger `--wait` for active indexing, a shorter or broader query for standalone sites, or a query/source change for package and repository targets. Partial and capped coverage are usable published evidence; they never imply indexing progress or retryability. Coverage details remain lossless in JSON, including the stable `siteKey`, canonical `siteUrl`, explicit null frontier state, artifact overflow, reason, estimate, and note. Human output derives the compact site host/path only from that contributor URL, so zero-hit and non-empty pages identify the same searched site without inspecting result URLs. If `siteUrl` is absent, the generic `site documentation` label is retained; site sources are numbered only when their displayed identities collide. +**Trust signals.** The JSON `sourceStatus` block is included when a source reports an actionable condition or a DOCS row discloses physical `contributors`. It retains each source's state, freshness, current-page `resultCount`, and exact repository or docpack metadata. Human output is intentionally smaller. Directly below the result count, current, searched, fully published sources collapse to `Searched: site ...; repo ... @ `. A single target needs no package label; multi-target searches retain labels only to disambiguate each source set. The summary does not repeat freshness, page coverage, or hit counts already conveyed by the result count. If a source is stale, provisional, partial, capped, not ready, unavailable, or missing published coverage details, a **Documentation sources** block appears before the hits and explains that exception in plain language, including whether each source was searched. A searched provisional contributor explicitly says that the provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline says only that the searched evidence returned no hits. Without a pending-evidence notice, CLI output then suggests one applicable next step: a larger `--wait` for active indexing, a shorter or broader query for standalone sites, or a query/source change for package and repository targets. Partial and capped coverage are usable published evidence; they never imply indexing progress or retryability. Coverage details remain lossless in JSON, including the stable `siteKey`, canonical `siteUrl`, explicit null frontier state, artifact overflow, reason, estimate, and note. Human output derives the compact site host/path only from that contributor URL, so zero-hit and non-empty pages identify the same searched site without inspecting result URLs. If `siteUrl` is absent, the generic `site documentation` label is retained; current CLI text adds numbers only when displayed identities collide. Contributor-bearing rows omit redundant pair-level `resultCount`, pair-level `coverage`, and healthy resolution metadata from the compact JSON projection. Other source-status signals remain unchanged: ignored / incompatible filters and query features, terminal indexing notes, promoted freshness warnings, and ordered standalone-site recovery targets. Site suggestions come from `suggestedSiteTargets`; the exact `suggestedSiteTargetsTruncated` Boolean is retained whenever suggestions are present. They are advisory labels to retry explicitly, not aliases, and the client never selects or retries one automatically. diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index bd9d47bb..7f35c139 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -232,7 +232,7 @@ test suite anchors the doc. ### Search output parity -Phase 1a changes MCP `search` and `search_status` default `text-v1` in place. +MCP `search` and `search_status` default `text-v1` evolve in place. The MCP text contract is outcome-first: one outcome line, one concise readiness and trust summary, result blocks, bounded alternatives or provenance, and one action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct; active @@ -253,7 +253,7 @@ only in the exact `Next: search_status search_ref=... wait_timeout_ms=...` action; terminal and unknown responses instead give an explicit no-poll instruction. -The CLI human renderer is unchanged in Phase 1a. Its `--json` output and MCP +The CLI human renderer remains unchanged. Its `--json` output and MCP `format: "json"` output remain the structured parity boundary: every result-bearing initial payload and stored `search_status.result` carries the backend's exact `partialResults: boolean`, including both `false` and `true`; diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index eb6f0ea9..7e372048 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -111,7 +111,7 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. -**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` even when otherwise healthy. Repository contributors expose normalized `repositoryUrl`, full `commitSha`, freshness, and current-page `resultCount`; docpacks expose stable `siteKey`, canonical `siteUrl`, and selected published coverage. The JSON projection preserves meaningful zero/null values and every selected docpack coverage field, but omits duplicate pair-level count/coverage and incidental healthy resolution metadata. MCP text-v1 uses one outcome-first readiness block: fully current searched sources collapse to `Searched: site ...; repository docs ... @ `, while waiting, available-but-unsearched, unavailable, stale, provisional, partial, capped, or coverage-undisclosed sources retain the one concise state needed to interpret the result. Docpack labels come only from contributor `siteUrl`, retain a canonical path scope, and are therefore identical for empty and non-empty result pages; returned hit URLs are never used to infer which site was searched. Target context is omitted for one target and retained only when multiple targets need disambiguation. A `SEARCHED` contributor with `PROVISIONAL` freshness explicitly says that a provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline scopes the claim to searched evidence. If `siteUrl` is absent, text uses the generic `site documentation` identity; site sources are numbered only when their displayed identities collide. JSON remains the exact source for stable keys, canonical URLs, and all coverage fields. Partial/capped coverage is published evidence, not a progress or retry signal. +**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` even when otherwise healthy. Repository contributors expose normalized `repositoryUrl`, full `commitSha`, freshness, and current-page `resultCount`; docpacks expose stable `siteKey`, canonical `siteUrl`, and selected published coverage. The JSON projection preserves meaningful zero/null values and every selected docpack coverage field, but omits duplicate pair-level count/coverage and incidental healthy resolution metadata. MCP text-v1 uses one outcome-first readiness block: fully current searched sources collapse to `Searched: site ...; repository docs ... @ `, while waiting, available-but-unsearched, unavailable, stale, provisional, partial, or capped sources retain the one concise state needed to interpret the result. Docpack labels use the canonical host/path from contributor `siteUrl`, then stable `siteKey`, then the retained target or generic site identity; returned hit URLs are never used to infer which site was searched. Target context is omitted for one target and retained only when multiple targets need disambiguation. A `SEARCHED` contributor with `PROVISIONAL` freshness explicitly says that a provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline scopes the claim to searched evidence. Multiple targets retain context labels when needed for disambiguation; the text does not synthesize contributor numbering. JSON remains the exact source for stable keys, canonical URLs, and all coverage fields. Partial/capped coverage is published evidence, not a progress or retry signal. `evidenceNotice` is carried once on initial and stored result envelopes. MCP text summarizes its presence once as `Evidence may change.` rather than copying opaque backend prose; JSON retains the exact notice. A `searchRef` is actionable only when rendered output supplies a `search_status` follow-up. Ordinary cases are known active progress (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result carrying an evidence notice. Terminal `DEFERRED` keeps `completed: false`, exact progress, and any stored result, but its `searchRef` no longer advances: callers use the disclosed evidence now and issue a new search later for a fresher snapshot. Terminal `DEFERRED`, `TIMEOUT`, and `FAILED` output never directs callers back to the same session. Session status is an open backend-owned string so adding an enum value does not invalidate the response. An unrecognized value is preserved in JSON and text with any disclosed evidence, but the client does not guess whether it is active or terminal and does not poll the same reference; it directs a later new search instead. Without a reference, the notice is the only retry-variability guidance. `search_status(includeResults: true)` uses the same result projection and formatter—contributors are never copied onto generic progress targets, and `allowPartialResults` retains its separate pair-omission meaning. diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 29172a81..3a9d67c8 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -320,6 +320,23 @@ describe("projectUnifiedSearchPresentation", () => { resultCount: 0, }); expect(presentation.sources).toEqual([]); + expect(presentation.warnings).toEqual([]); + }); + + it("retains parser warnings from an initial progress-only query", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + query: { raw: "router", warnings: ["unknown qualifier"] }, + }), + ); + + expect(presentation.query).toEqual({ + raw: "router", + warnings: ["unknown qualifier"], + }); + expect(presentation.warnings).toEqual([ + { kind: "query", message: "unknown qualifier" }, + ]); }); it("groups searched, waiting, and available documentation contributors", () => { diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 1a20ce20..5bf7b6a8 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -211,13 +211,15 @@ export function projectUnifiedSearchPresentation( const sources = projectSources(sourceStatus); const siteSuggestions = projectSiteSuggestions(sourceStatus); const trustLimits = projectTrustLimits(snapshot, sources, sourceStatus); - const warnings = projectWarnings(snapshot?.query, sourceStatus); + const query = + snapshot?.query ?? ("query" in payload ? payload.query : undefined); + const warnings = projectWarnings(query, sourceStatus); const alternatives = projectAlternatives(progress, sourceStatus); return { availability, lifecycle, - query: snapshot?.query ?? extractQuery(payload), + query, searchRef: extractSearchRef(payload), progress: projectProgress(progress), targets: projectTargets(progress), @@ -266,13 +268,6 @@ function extractSnapshot( return undefined; } -function extractQuery( - payload: UnifiedSearchPresentationInput, -): UnifiedSearchQueryEcho | undefined { - if ("query" in payload) return payload.query; - return undefined; -} - function extractSearchRef( payload: UnifiedSearchPresentationInput, ): string | undefined { diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 662df9c0..b377ef49 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -272,6 +272,55 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("Do not repeat search.\nNext:"); }); + it("renders an initial progress-only parser warning once below the outcome", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + query: { raw: "router", warnings: ["unknown qualifier"] }, + }), + ); + + expect(firstLine(text)).toBe("Indexing - no result snapshot returned yet"); + expect(text).toContain("Warnings:\n - unknown qualifier"); + expect(text.match(/unknown qualifier/g)).toHaveLength(1); + expect(text.indexOf("Warnings:")).toBeGreaterThan(0); + }); + + it("uses the scoped site URL before the site key for searched and available docs", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:example@1.0.0", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 0, + siteKey: "example.com", + siteUrl: "https://example.com/reference", + }, + { + kind: "DOCPACK", + state: "READY", + resultCount: 0, + siteKey: "example.com", + siteUrl: "https://example.com/guide", + }, + ], + }), + ], + }), + ); + + expect(text).toContain( + "Searched: site docs (example.com/reference) for npm:example@1.0.0", + ); + expect(text).toContain( + "Available but not searched: example.com/guide docs", + ); + }); + it("renders site suggestions once without selecting them during active polling", () => { const sourceStatus = [ source({ diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 15e97d5b..4e006859 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -277,7 +277,7 @@ function formatSourceReadiness( } const identity = group.kind === "site_docs" - ? `${entry.siteKey ?? entry.siteUrl ?? entry.target} docs` + ? `${formatDocumentationSourceIdentity(group, entry)} docs` : `${sourceLabel} (${entry.target})`; return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix}`; } @@ -289,7 +289,8 @@ function formatDocumentationSourceIdentity( if (group.kind === "repository_docs") { return `${entry.repositoryUrl ?? entry.target}${entry.commitSha ? ` @ ${entry.commitSha}` : ""}`; } - return entry.siteKey ?? entry.siteUrl ?? entry.target; + const siteIdentity = formatDocumentationSiteIdentity(entry.siteUrl); + return siteIdentity ?? entry.siteKey ?? entry.target; } function sourceGroupLabel(kind: UnifiedSearchSourceGroup["kind"]): string { From 7a3235252b45c861108595d6932c1a1818fd0cd2 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 08:47:11 +0300 Subject: [PATCH 13/46] chore: remove dead unified search trailer Remove the unused legacy unified-search trailer and its private-only helpers after the MCP presentation migration. Keep exported CLI helpers and correct the remaining lint findings. --- .../src/shared/unified-search-presentation.ts | 1 - .../mcp/src/shared/unified-search-text.ts | 75 ------------------- packages/mcp/src/tools/search-status.test.ts | 2 +- 3 files changed, 1 insertion(+), 77 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 5bf7b6a8..03e09fe0 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -7,7 +7,6 @@ import type { UnifiedSearchSourceStatusPayload, UnifiedSearchStatusCompletedPayload, UnifiedSearchStatusIncompletePayload, - UnifiedSearchStatusResultPayload, } from "./unified-search-response.js"; export type UnifiedSearchPresentationInput = diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 4e006859..a81d7c2b 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -675,75 +675,6 @@ function formatLineRange(start?: number, end?: number): string { return `:${start}-${end}`; } -function buildTrailer( - payload: SearchSuccessPayload, - options: { includeWarnings: boolean; includeSourceStatus: boolean }, -): string[] { - const lines: string[] = []; - - if (options.includeWarnings) appendWarnings(lines, payload.warnings); - - if (payload.hasMore) { - const nextOffsetHint = - typeof payload.nextOffset === "number" - ? ` Pass offset=${payload.nextOffset} for the next page or limit=N to widen.` - : " Pass limit=N to widen."; - lines.push(`More hits available.${nextOffsetHint}`); - } - - if (options.includeSourceStatus) { - appendSourceStatusNotes(lines, payload.sourceStatus); - } - - appendEvidenceNotice(lines, payload.evidenceNotice); - - const progress = "progress" in payload ? payload.progress : undefined; - if (progress?.targets?.length) { - lines.push("progress targets:"); - for (const target of progress.targets) { - lines.push(` - ${formatProgressTarget(target)}`); - } - } - - if (!payload.completed && payload.searchRef) { - const status = payload.progress?.status; - const action = - status === "DEFERRED" - ? "Search session deferred." - : status === "TIMEOUT" - ? "Search timed out before completion." - : status === "FAILED" - ? "Search failed before completion." - : status === "SEARCHING" - ? "Search in progress." - : status === "PENDING" || status === "INDEXING" - ? "Indexing in progress." - : status - ? `Search returned status ${status}.` - : "Search status is unavailable."; - if (payload.progress) { - lines.push( - `progress: ${payload.progress.targetsReady}/${payload.progress.targetsTotal} targets ready.`, - ); - } - lines.push(action); - appendIncompleteSearchNextAction(lines, status, payload.searchRef); - } else if (payload.evidenceNotice && payload.searchRef) { - appendEvidenceSearchStatusNextAction(lines, payload.searchRef); - } - - return lines; -} - -function appendEvidenceSearchStatusNextAction( - lines: string[], - searchRef: string, -): void { - lines.push( - `next: call search_status with search_ref=${JSON.stringify(searchRef)} and wait_timeout_ms=${DEFAULT_WAIT_TIMEOUT_MS}.`, - ); -} - export function appendIncompleteSearchNextAction( lines: string[], status: string | undefined, @@ -779,12 +710,6 @@ export function appendIncompleteSearchNextAction( ); } -function appendWarnings(lines: string[], warnings: string[] | undefined): void { - if (!warnings || warnings.length === 0) return; - lines.push("warnings:"); - for (const warning of warnings) lines.push(` - ${warning}`); -} - export function appendSourceStatusNotes( lines: string[], sourceStatus: diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index a2eb60ae..40c75791 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -613,7 +613,7 @@ describe("searchStatusTool", () => { const warning = "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes."; expect(text).toContain("Target: requested npm:express latest"); - expect(text).toContain("Evidence: " + warning); + expect(text).toContain(`Evidence: ${warning}`); expect(text).toContain(warning); expect(text.split(warning)).toHaveLength(2); }); From b17adbe4934c59fcabad0c01695679fec1f4746b Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 09:00:40 +0300 Subject: [PATCH 14/46] fix: classify symbol readiness as code Map symbol source-status entries to the code readiness group so MCP output reflects the evidence taxonomy. Add presentation and text regressions for the exact symbol source shape. --- .../unified-search-presentation.test.ts | 29 +++++++++++++++++++ .../src/shared/unified-search-presentation.ts | 3 +- .../src/shared/unified-search-text.test.ts | 18 ++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 3a9d67c8..960556fa 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -232,6 +232,35 @@ describe("projectUnifiedSearchPresentation", () => { }); }); + it("groups symbol source readiness with code", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + query: { raw: "router", sources: ["symbol"] }, + results: [], + sourceStatus: [ + source({ + source: "symbol", + codeIndexState: "CURRENT", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.sources).toEqual([ + { + kind: "code", + entries: [ + { + state: "searched", + target: "npm:express@4.18.2", + resultCount: 0, + }, + ], + }, + ]); + }); + it.each([ ["PENDING", "no_snapshot"], ["INDEXING", "no_snapshot"], diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 03e09fe0..c08c9ac7 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -402,7 +402,8 @@ function appendSourceEntry( function sourceKind( entry: UnifiedSearchSourceStatusPayload, ): UnifiedSearchSourceKind { - if (entry.source.toLowerCase() === "code") return "code"; + const source = entry.source.toLowerCase(); + if (source === "code" || source === "symbol") return "code"; return isSiteTarget(entry.targetLabel, entry) ? "site_docs" : "repository_docs"; diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index b377ef49..d61f09f0 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -216,6 +216,24 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("renders symbol source readiness as code", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + query: { raw: "router", sources: ["symbol"] }, + sourceStatus: [ + source({ + source: "symbol", + codeIndexState: "CURRENT", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toContain("Searched: code"); + expect(text).not.toContain("repository docs"); + }); + it("renders the supplied n8n active empty snapshot with one concise readiness block", () => { const text = renderUnifiedSearchSuccess(n8nActiveEmpty()); const lines = text.split("\n"); From b1aa34a4ca1f12a3714fc9a0062462c7152cdb77 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 09:21:01 +0300 Subject: [PATCH 15/46] fix: harden unified search provenance Classify unknown source states conservatively, avoid unsupported single-target claims, and deduplicate stale evidence. Keep neutral documentation labels, remove dead text helpers, and separate hit blocks from follow-up actions. --- .../unified-search-presentation.test.ts | 57 +++++ .../src/shared/unified-search-presentation.ts | 57 ++++- .../src/shared/unified-search-text.test.ts | 68 +++++- .../mcp/src/shared/unified-search-text.ts | 222 +++--------------- packages/mcp/src/tools/search-status.test.ts | 3 +- 5 files changed, 200 insertions(+), 207 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 960556fa..5a18718e 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -261,6 +261,63 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); + it.each(["MISSING", "UNRESOLVABLE", "FUTURE_STATE"] as const)( + "treats source state %s as unavailable and suppresses pivots", + (state) => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + source: "code", + codeIndexState: state, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.sources).toEqual([ + { + kind: "code", + entries: [ + { + state: "unavailable", + target: "npm:express@4.18.2", + resultCount: 0, + }, + ], + }, + ]); + expect(presentation.action).toEqual({ kind: "none" }); + }, + ); + + it.each(["docs", "auto"] as const)( + "uses neutral docs provenance for contributor-less %s sources", + (sourceName) => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [source({ source: sourceName, resultCount: 0 })], + }), + ); + + expect(presentation.sources).toEqual([ + { + kind: "docs", + entries: [ + { + state: "searched", + target: "npm:express@4.18.2", + resultCount: 0, + }, + ], + }, + ]); + }, + ); + it.each([ ["PENDING", "no_snapshot"], ["INDEXING", "no_snapshot"], diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index c08c9ac7..ff5490e6 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -37,7 +37,11 @@ export type UnifiedSearchLifecycle = | { kind: "terminal"; status: UnifiedSearchTerminalStatus } | { kind: "unknown"; status?: string }; -export type UnifiedSearchSourceKind = "code" | "repository_docs" | "site_docs"; +export type UnifiedSearchSourceKind = + | "code" + | "docs" + | "repository_docs" + | "site_docs"; export type UnifiedSearchSourceReadiness = | "searched" | "waiting" @@ -404,9 +408,8 @@ function sourceKind( ): UnifiedSearchSourceKind { const source = entry.source.toLowerCase(); if (source === "code" || source === "symbol") return "code"; - return isSiteTarget(entry.targetLabel, entry) - ? "site_docs" - : "repository_docs"; + if (isSiteTarget(entry.targetLabel, entry)) return "site_docs"; + return entry.targetResolution?.served?.repoUrl ? "repository_docs" : "docs"; } function contributorIdentity( @@ -476,14 +479,22 @@ function sourceTarget(entry: UnifiedSearchSourceStatusPayload): string { function sourceState( entry: UnifiedSearchSourceStatusPayload, ): UnifiedSearchSourceReadiness { - const states = [entry.indexingStatus, entry.codeIndexState]; + const states = [entry.indexingStatus, entry.codeIndexState].filter( + (state): state is string => Boolean(state), + ); + if (states.length === 0) return "searched"; if (states.some((state) => state === "INDEXING" || state === "PENDING")) { return "waiting"; } - if (states.some((state) => state === "FAILED" || state === "UNAVAILABLE")) { - return "unavailable"; - } - return "searched"; + const searchableStates = new Set([ + "CURRENT", + "INDEXED", + "PROVISIONAL", + "STALE", + ]); + return states.every((state) => searchableStates.has(state)) + ? "searched" + : "unavailable"; } function contributorState( @@ -507,12 +518,24 @@ function projectTrustLimits( sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, ): UnifiedSearchTrustLimit[] { const limits: UnifiedSearchTrustLimit[] = []; - const seen = new Set(); + const seen = new Map(); const add = (limit: UnifiedSearchTrustLimit): void => { - const key = JSON.stringify(limit); - if (!seen.has(key)) { - seen.add(key); + const key = + limit.kind === "stale" + ? `stale:${limit.servedTarget ?? limit.target ?? ""}` + : JSON.stringify(limit); + const existingIndex = seen.get(key); + const existing = + existingIndex === undefined ? undefined : limits[existingIndex]; + if (existingIndex === undefined) { + seen.set(key, limits.length); limits.push(limit); + } else if ( + limit.kind === "stale" && + existing?.kind === "stale" && + staleSpecificity(limit) > staleSpecificity(existing) + ) { + limits[existingIndex] = limit; } }; @@ -585,6 +608,14 @@ function projectTrustLimits( return limits; } +function staleSpecificity( + limit: Extract, +): number { + return [limit.requestedTarget, limit.freshTarget, limit.servedTarget].filter( + Boolean, + ).length; +} + function addCoverage( add: (limit: UnifiedSearchTrustLimit) => void, source: UnifiedSearchSourceKind, diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index d61f09f0..f31159e4 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -234,6 +234,20 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("repository docs"); }); + it.each(["docs", "auto"] as const)( + "uses a neutral docs label for contributor-less %s sources", + (sourceName) => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [source({ source: sourceName, resultCount: 0 })], + }), + ); + + expect(text).toContain("Searched: docs (npm:express@4.18.2)"); + expect(text).not.toContain("repository docs"); + }, + ); + it("renders the supplied n8n active empty snapshot with one concise readiness block", () => { const text = renderUnifiedSearchSuccess(n8nActiveEmpty()); const lines = text.split("\n"); @@ -331,12 +345,11 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain( - "Searched: site docs (example.com/reference) for npm:example@1.0.0", - ); + expect(text).toContain("Searched: site docs (example.com/reference)"); expect(text).toContain( "Available but not searched: example.com/guide docs", ); + expect(text).not.toContain("for npm:example@1.0.0"); }); it("renders site suggestions once without selecting them during active polling", () => { @@ -464,6 +477,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); + expect(firstLine(text)).toBe("No results returned"); expect(text).toContain( "Waiting: code for npm:one@1.0.0, code for npm:two@2.0.0", ); @@ -481,6 +495,18 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("omits a singular outcome target when hits span multiple targets", () => { + const text = renderUnifiedSearchSuccess( + completed([ + codeHit({ target: "npm:one@1.0.0" }), + codeHit({ target: "npm:two@2.0.0" }), + ]), + ); + + expect(firstLine(text)).toBe("2 results"); + expect(firstLine(text)).not.toContain(" from "); + }); + it.each([ ["PENDING", "Preparing"], ["INDEXING", "Indexing"], @@ -596,6 +622,38 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("shorten or broaden query"); }); + it("deduplicates stale evidence for the same served target", () => { + const text = renderUnifiedSearchSuccess( + completed( + [ + codeHit({ + target: "npm:express@5.1.0", + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + freshness: "STALE", + }), + ], + { + sourceStatus: [ + source({ + targetLabel: "npm:express@5.1.0", + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + codeIndexState: "STALE", + }), + ], + }, + ), + ); + + expect(text.match(/Evidence:/g)).toHaveLength(1); + expect(text).toContain( + "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", + ); + }); + it("turns an evidence notice into one concise mutable-evidence action", () => { const text = renderUnifiedSearchSuccess( completed([], { @@ -626,6 +684,10 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain( 'Next: search_status search_ref="search-ref-results" wait_timeout_ms=20000', ); + const lines = text.split("\n"); + const actionLine = lines.indexOf("Do not repeat immediately."); + expect(actionLine).toBeGreaterThan(0); + expect(lines[actionLine - 1]).toBe(""); }); it("prints query and structured constraint warnings once below the outcome", () => { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index a81d7c2b..a762f79d 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -35,15 +35,14 @@ import { type UnifiedSearchTrustLimit, type UnifiedSearchWarning, } from "./unified-search-presentation.js"; -import { - isActiveUnifiedSearchSessionStatus, - type UnifiedSearchCompletedPayload, - type UnifiedSearchDocumentationContributorPayload, - type UnifiedSearchErrorPayload, - type UnifiedSearchHitPayload, - type UnifiedSearchIncompletePayload, - type UnifiedSearchQueryEcho, - type UnifiedSearchSourceStatusPayload, +import type { + UnifiedSearchCompletedPayload, + UnifiedSearchDocumentationContributorPayload, + UnifiedSearchErrorPayload, + UnifiedSearchHitPayload, + UnifiedSearchIncompletePayload, + UnifiedSearchQueryEcho, + UnifiedSearchSourceStatusPayload, } from "./unified-search-response.js"; const SUMMARY_WRAP_WIDTH = 76; @@ -83,6 +82,19 @@ export function renderUnifiedSearchPresentationText( appendUnifiedSearchHits(lines, result.results); } + const hasPostResultBlock = + presentation.hasMore || + presentation.alternatives.length > 0 || + presentation.siteSuggestions.length > 0 || + presentation.action.kind !== "none"; + if ( + result.results.length > 0 && + hasPostResultBlock && + lines[lines.length - 1] !== "" + ) { + lines.push(""); + } + if (presentation.hasMore) { if (lines[lines.length - 1] !== "") lines.push(""); const nextOffsetHint = @@ -151,6 +163,13 @@ function presentationTarget( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], ): string | undefined { + const targetIdentities = new Set([ + ...results.map((result) => result.target), + ...presentation.sources.flatMap((group) => + group.entries.map((entry) => entry.contextTarget ?? entry.target), + ), + ]); + if (targetIdentities.size > 1) return undefined; const hitTarget = results[0]?.target; if (hitTarget) return hitTarget; const target = presentation.targets[0]; @@ -213,8 +232,7 @@ function appendPresentationSources( group.entries.map((entry) => entry.contextTarget ?? entry.target), ), ); - const showTargetContext = - contextTargets.size > 1 || groups.some((group) => group.entries.length > 1); + const showTargetContext = contextTargets.size > 1; for (const { state, label } of states) { const entries = groups.flatMap((group) => group.entries @@ -289,12 +307,15 @@ function formatDocumentationSourceIdentity( if (group.kind === "repository_docs") { return `${entry.repositoryUrl ?? entry.target}${entry.commitSha ? ` @ ${entry.commitSha}` : ""}`; } + if (group.kind === "docs") return entry.target; const siteIdentity = formatDocumentationSiteIdentity(entry.siteUrl); return siteIdentity ?? entry.siteKey ?? entry.target; } function sourceGroupLabel(kind: UnifiedSearchSourceGroup["kind"]): string { switch (kind) { + case "docs": + return "docs"; case "repository_docs": return "repository docs"; case "site_docs": @@ -521,24 +542,6 @@ function capitalize(value: string): string { : value; } -export function noHitsYetMessage( - progress: { status?: string } | undefined, -): string { - const status = progress?.status; - if (status === "DEFERRED") { - return "No result snapshot is available for this deferred session."; - } - if (status === "TIMEOUT") return "No hits - search timed out."; - if (status === "FAILED") return "No hits - search failed."; - if (status === "SEARCHING") return "No hits yet - searching."; - if (status === "PENDING" || status === "INDEXING") { - return "No hits yet - indexing."; - } - return status - ? `No result snapshot is available for search status ${status}.` - : "No hits yet - indexing."; -} - /** Render an error envelope as compact text. */ export function renderUnifiedSearchError( payload: UnifiedSearchErrorPayload, @@ -675,79 +678,6 @@ function formatLineRange(start?: number, end?: number): string { return `:${start}-${end}`; } -export function appendIncompleteSearchNextAction( - lines: string[], - status: string | undefined, - searchRef: string, -): void { - if (status === "DEFERRED") { - lines.push( - "Background lifecycle work continues outside this search session.", - ); - lines.push("Use any disclosed evidence now."); - lines.push("Do not call search_status again for this session."); - lines.push("next: rerun search later for a fresher snapshot."); - return; - } - - if (status === "FAILED" || status === "TIMEOUT") { - lines.push("Do not call search_status again for this session."); - lines.push("next: rerun search."); - return; - } - - if (status !== undefined && !isActiveUnifiedSearchSessionStatus(status)) { - lines.push("This client does not recognize that status."); - lines.push("Use any disclosed evidence now."); - lines.push("Do not call search_status again for this session."); - lines.push("next: rerun search later."); - return; - } - - lines.push("Do not repeat search."); - lines.push( - `next: call search_status with search_ref=${JSON.stringify(searchRef)} and wait_timeout_ms=${DEFAULT_WAIT_TIMEOUT_MS}.`, - ); -} - -export function appendSourceStatusNotes( - lines: string[], - sourceStatus: - | UnifiedSearchCompletedPayload["sourceStatus"] - | UnifiedSearchIncompletePayload["sourceStatus"], -): void { - if (!sourceStatus || sourceStatus.length === 0) return; - const noted = sourceStatus.filter(hasSourceStatusNote); - if (noted.length === 0) return; - lines.push("source notes:"); - for (const entry of noted) { - lines.push(` - ${formatSourceStatus(entry)}`); - for (const guidance of formatSuggestedSiteTargetGuidance(entry)) { - lines.push(` ${guidance}`); - } - } -} - -function hasSourceStatusNote(entry: UnifiedSearchSourceStatusPayload): boolean { - return Boolean( - entry.requestedTarget || - entry.freshTarget || - entry.servedTarget || - entry.targetResolution || - entry.indexingStatus || - entry.codeIndexState || - typeof entry.resultCount === "number" || - entry.ignoredFilters?.length || - entry.incompatibleFilters?.length || - entry.ignoredQueryFeatures?.length || - entry.incompatibleQueryFeatures?.length || - entry.suggestedSiteTargets?.length || - entry.suggestedSiteTargetsTruncated || - entry.note || - entry.coverage, - ); -} - export interface DocumentationSourceResult { target: string; } @@ -855,13 +785,6 @@ function formatDocumentationContributor( return `${identity} - ${details.join("; ")}`; } -export function appendEvidenceNotice( - lines: string[], - evidenceNotice: string | undefined, -): void { - if (evidenceNotice) lines.push(`evidence notice: ${evidenceNotice}`); -} - function formatDocumentationContributorState( state: UnifiedSearchDocumentationContributorPayload["state"], ): string { @@ -1211,67 +1134,6 @@ export function describeFreshness(value: string): string { } } -export function formatSourceStatus(entry: { - source: string; - targetLabel: string; - requestedTarget?: string; - freshTarget?: string; - servedTarget?: string; - targetResolution?: LeanTargetResolution; - indexingStatus?: string; - codeIndexState?: string; - resultCount?: number; - ignoredFilters?: string[]; - incompatibleFilters?: string[]; - ignoredQueryFeatures?: string[]; - incompatibleQueryFeatures?: string[]; - note?: string; -}): string { - const terminalReason = terminalLifecycleReason(entry); - if (terminalReason) { - return `${entry.source} (${entry.targetLabel})${SEP}${terminalReason}`; - } - - const parts: string[] = [`${entry.source} (${entry.targetLabel})`]; - if (entry.requestedTarget) parts.push(`requested=${entry.requestedTarget}`); - if (entry.freshTarget) parts.push(`fresh=${entry.freshTarget}`); - if (entry.servedTarget && entry.servedTarget !== entry.targetLabel) { - parts.push(`served=${entry.servedTarget}`); - } - if (typeof entry.resultCount === "number") { - parts.push(`results=${entry.resultCount}`); - } - if (entry.indexingStatus) parts.push(`indexState=${entry.indexingStatus}`); - if (entry.codeIndexState) { - parts.push( - `codeIndex=${ - entry.codeIndexState === "PROVISIONAL" - ? describeFreshness(entry.codeIndexState) - : entry.codeIndexState - }`, - ); - } - if (entry.ignoredFilters?.length) { - parts.push(`ignored=${entry.ignoredFilters.join(",")}`); - } - if (entry.incompatibleFilters?.length) { - parts.push(`incompatible=${entry.incompatibleFilters.join(",")}`); - } - if (entry.ignoredQueryFeatures?.length) { - parts.push(`ignoredQuery=${entry.ignoredQueryFeatures.join(",")}`); - } - if (entry.incompatibleQueryFeatures?.length) { - parts.push( - `incompatibleQuery=${entry.incompatibleQueryFeatures.join(",")}`, - ); - } - if (entry.note) parts.push(entry.note); - for (const note of buildTargetResolutionNotes(entry.targetResolution)) { - parts.push(note); - } - return parts.join(SEP); -} - /** Render replayable standalone-site recovery guidance from structured fields. */ export function formatSuggestedSiteTargetGuidance(entry: { suggestedSiteTargets?: string[]; @@ -1289,26 +1151,6 @@ export function formatSuggestedSiteTargetGuidance(entry: { return lines; } -function terminalLifecycleReason(entry: { - indexingStatus?: string; - codeIndexState?: string; - note?: string; -}): string | undefined { - const states = Array.from( - new Set([entry.indexingStatus, entry.codeIndexState].filter(Boolean)), - ) as string[]; - const terminalStates = states.filter( - (state) => - !isHealthySearchLifecycleState(state) && - state !== "INDEXING" && - state !== "STALE" && - state !== "PROVISIONAL", - ); - if (terminalStates.length === 0) return undefined; - const status = terminalStates.join("/"); - return entry.note ? `${entry.note} (${status})` : `status ${status}`; -} - function quote(value: string): string { // Use single quotes when the value already contains a double quote; // agents read either form. JSON-escape would be over-engineering for diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index 40c75791..b1c2933f 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -761,7 +761,8 @@ describe("searchStatusTool", () => { expect(text).toContain( "No results returned from github:githits-com/no-such-repo", ); - expect(text).toContain("Searched: code"); + expect(text).toContain("Unavailable: code"); + expect(text).not.toContain("Searched: code"); expect(text).not.toContain("Repository ref cannot be resolved"); expect(text).not.toContain("state=indexing"); }); From 0467e7b8819d3911c3fe501972a873d33b3ef2a5 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 09:32:18 +0300 Subject: [PATCH 16/46] refactor: simplify unified search projection Reduce duplicate snapshot, identity, coverage, constraint, alternative, and action plumbing while preserving source-state safety, provenance distinctions, trust deduplication, and multi-target behavior. --- .../src/shared/unified-search-presentation.ts | 260 +++++++----------- 1 file changed, 104 insertions(+), 156 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index ff5490e6..7baa9af4 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1,4 +1,5 @@ import type { + LeanDocCoverage, UnifiedSearchCompletedPayload, UnifiedSearchHitPayload, UnifiedSearchIncompletePayload, @@ -59,6 +60,20 @@ export interface UnifiedSearchSourceEntry { siteUrl?: string; } +type SourceIdentity = Pick< + UnifiedSearchSourceEntry, + | "target" + | "contextTarget" + | "repositoryUrl" + | "commitSha" + | "siteKey" + | "siteUrl" +>; + +type Coverage = Omit & { + frontierRemaining?: number | null; +}; + export interface UnifiedSearchSourceGroup { kind: UnifiedSearchSourceKind; entries: UnifiedSearchSourceEntry[]; @@ -218,12 +233,13 @@ export function projectUnifiedSearchPresentation( snapshot?.query ?? ("query" in payload ? payload.query : undefined); const warnings = projectWarnings(query, sourceStatus); const alternatives = projectAlternatives(progress, sourceStatus); + const searchRef = "searchRef" in payload ? payload.searchRef : undefined; return { availability, lifecycle, query, - searchRef: extractSearchRef(payload), + searchRef, progress: projectProgress(progress), targets: projectTargets(progress), hasMore: snapshot?.hasMore ?? false, @@ -233,7 +249,7 @@ export function projectUnifiedSearchPresentation( warnings, alternatives, action: projectAction({ - payload, + searchRef, snapshot, lifecycle, availability, @@ -248,33 +264,17 @@ function extractSnapshot( payload: UnifiedSearchPresentationInput, ): SnapshotFacts | undefined { if ("result" in payload) return payload.result; - if (payload.completed) { - return { - query: payload.query, - partialResults: payload.partialResults, - hasMore: payload.hasMore, - results: payload.results, - sourceStatus: payload.sourceStatus, - evidenceNotice: payload.evidenceNotice, - }; + if (!("partialResults" in payload) || payload.partialResults === undefined) { + return undefined; } - if ("partialResults" in payload && payload.partialResults !== undefined) { - return { - query: payload.query, - partialResults: payload.partialResults, - hasMore: payload.hasMore, - results: payload.results, - sourceStatus: payload.sourceStatus, - evidenceNotice: payload.evidenceNotice, - }; - } - return undefined; -} - -function extractSearchRef( - payload: UnifiedSearchPresentationInput, -): string | undefined { - return "searchRef" in payload ? payload.searchRef : undefined; + return { + query: "query" in payload ? payload.query : undefined, + partialResults: payload.partialResults, + hasMore: payload.hasMore, + results: payload.results, + sourceStatus: payload.sourceStatus, + evidenceNotice: payload.evidenceNotice, + }; } function projectProgress( @@ -334,17 +334,15 @@ function projectAvailability( return { kind: "no_snapshot", hasSnapshot: false, resultCount: 0 }; } const resultCount = snapshot.results.length; - if (resultCount === 0) { - return { kind: "empty", hasSnapshot: true, resultCount }; - } - if (snapshot.partialResults) { - return { kind: "partial", hasSnapshot: true, resultCount }; - } - return { - kind: lifecycle.kind === "active" ? "interim" : "final", - hasSnapshot: true, - resultCount, - }; + const kind = + resultCount === 0 + ? "empty" + : snapshot.partialResults + ? "partial" + : lifecycle.kind === "active" + ? "interim" + : "final"; + return { kind, hasSnapshot: true, resultCount }; } function projectSources( @@ -417,15 +415,7 @@ function contributorIdentity( contributor: NonNullable< UnifiedSearchSourceStatusPayload["contributors"] >[number], -): Pick< - UnifiedSearchSourceEntry, - | "target" - | "contextTarget" - | "repositoryUrl" - | "commitSha" - | "siteKey" - | "siteUrl" -> { +): SourceIdentity { const contextTarget = sourceTarget(entry); const target = contributor.kind === "REPOSITORY_DOCS" @@ -446,15 +436,7 @@ function contributorIdentity( function sourceIdentity( entry: UnifiedSearchSourceStatusPayload, kind: UnifiedSearchSourceKind, -): Pick< - UnifiedSearchSourceEntry, - | "target" - | "contextTarget" - | "repositoryUrl" - | "commitSha" - | "siteKey" - | "siteUrl" -> { +): SourceIdentity { const target = sourceTarget(entry); const contextTarget = entry.requestedTarget ?? entry.freshTarget; const context = @@ -483,16 +465,12 @@ function sourceState( (state): state is string => Boolean(state), ); if (states.length === 0) return "searched"; - if (states.some((state) => state === "INDEXING" || state === "PENDING")) { + if (states.some((state) => ["INDEXING", "PENDING"].includes(state))) { return "waiting"; } - const searchableStates = new Set([ - "CURRENT", - "INDEXED", - "PROVISIONAL", - "STALE", - ]); - return states.every((state) => searchableStates.has(state)) + return states.every((state) => + ["CURRENT", "INDEXED", "PROVISIONAL", "STALE"].includes(state), + ) ? "searched" : "unavailable"; } @@ -500,16 +478,12 @@ function sourceState( function contributorState( state: "SEARCHED" | "READY" | "PENDING" | "UNAVAILABLE", ): UnifiedSearchSourceReadiness { - switch (state) { - case "SEARCHED": - return "searched"; - case "READY": - return "available_not_searched"; - case "PENDING": - return "waiting"; - case "UNAVAILABLE": - return "unavailable"; - } + return { + SEARCHED: "searched", + READY: "available_not_searched", + PENDING: "waiting", + UNAVAILABLE: "unavailable", + }[state] as UnifiedSearchSourceReadiness; } function projectTrustLimits( @@ -517,25 +491,20 @@ function projectTrustLimits( sources: UnifiedSearchSourceGroup[], sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, ): UnifiedSearchTrustLimit[] { - const limits: UnifiedSearchTrustLimit[] = []; - const seen = new Map(); + const limits = new Map(); const add = (limit: UnifiedSearchTrustLimit): void => { const key = limit.kind === "stale" ? `stale:${limit.servedTarget ?? limit.target ?? ""}` : JSON.stringify(limit); - const existingIndex = seen.get(key); - const existing = - existingIndex === undefined ? undefined : limits[existingIndex]; - if (existingIndex === undefined) { - seen.set(key, limits.length); - limits.push(limit); - } else if ( - limit.kind === "stale" && - existing?.kind === "stale" && - staleSpecificity(limit) > staleSpecificity(existing) + const existing = limits.get(key); + if ( + existing === undefined || + (limit.kind === "stale" && + existing.kind === "stale" && + staleSpecificity(limit) > staleSpecificity(existing)) ) { - limits[existingIndex] = limit; + limits.set(key, limit); } }; @@ -605,7 +574,7 @@ function projectTrustLimits( if (snapshot?.evidenceNotice !== undefined) { add({ kind: "mutable_evidence" }); } - return limits; + return [...limits.values()]; } function staleSpecificity( @@ -620,20 +589,9 @@ function addCoverage( add: (limit: UnifiedSearchTrustLimit) => void, source: UnifiedSearchSourceKind, target: string, - coverage: - | { - coverageState: string; - pagesCrawled?: number; - frontierRemaining?: number | null; - estimatedTotalPages?: number; - } - | undefined, + coverage: Coverage | undefined, ): void { - if (!coverage) return; - if ( - coverage.coverageState !== "PARTIAL" && - coverage.coverageState !== "CAPPED" - ) { + if (!coverage || !["PARTIAL", "CAPPED"].includes(coverage.coverageState)) { return; } add({ @@ -655,19 +613,21 @@ function addConstraints( entry: UnifiedSearchSourceStatusPayload, ): void { const target = entry.targetLabel; - const constraints: Array< - [UnifiedSearchConstraintKind, string[] | undefined] - > = [ + for (const [constraint, values] of sourceConstraints(entry)) { + if (values?.length) + add({ kind: "constraint", constraint, source: target, values }); + } +} + +function sourceConstraints( + entry: UnifiedSearchSourceStatusPayload, +): Array<[UnifiedSearchConstraintKind, string[] | undefined]> { + return [ ["ignored_filter", entry.ignoredFilters], ["incompatible_filter", entry.incompatibleFilters], ["ignored_query_feature", entry.ignoredQueryFeatures], ["incompatible_query_feature", entry.incompatibleQueryFeatures], ]; - for (const [constraint, values] of constraints) { - if (values && values.length > 0) { - add({ kind: "constraint", constraint, source: target, values }); - } - } } function projectWarnings( @@ -680,16 +640,8 @@ function projectWarnings( } for (const entry of sourceStatus ?? []) { const source = entry.targetLabel; - const constraints: Array< - [UnifiedSearchConstraintKind, string[] | undefined] - > = [ - ["ignored_filter", entry.ignoredFilters], - ["incompatible_filter", entry.incompatibleFilters], - ["ignored_query_feature", entry.ignoredQueryFeatures], - ["incompatible_query_feature", entry.incompatibleQueryFeatures], - ]; - for (const [kind, values] of constraints) { - if (values && values.length > 0) warnings.push({ kind, source, values }); + for (const [kind, values] of sourceConstraints(entry)) { + if (values?.length) warnings.push({ kind, source, values }); } } return warnings; @@ -699,9 +651,8 @@ function projectAlternatives( progress: UnifiedSearchProgressPayload | undefined, sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, ): UnifiedSearchAlternativeFacts[] { - const candidates: CandidateSet[] = []; - for (const target of progress?.targets ?? []) { - candidates.push({ + const candidates: CandidateSet[] = [ + ...(progress?.targets ?? []).map((target) => ({ target: target.requested, versions: target.targetResolution?.availableVersions ?? @@ -711,18 +662,21 @@ function projectAlternatives( target.targetResolution?.availableRefs ?? target.availableRefs ?? [], suggestedRefs: target.targetResolution?.suggestedRefs ?? target.suggestedRefs ?? [], - }); - } - for (const entry of sourceStatus ?? []) { - const resolution = entry.targetResolution; - if (!resolution) continue; - candidates.push({ - target: sourceTarget(entry), - versions: resolution.availableVersions, - refs: resolution.availableRefs, - suggestedRefs: resolution.suggestedRefs ?? [], - }); - } + })), + ...(sourceStatus ?? []).flatMap((entry) => { + const resolution = entry.targetResolution; + return resolution + ? [ + { + target: sourceTarget(entry), + versions: resolution.availableVersions, + refs: resolution.availableRefs, + suggestedRefs: resolution.suggestedRefs ?? [], + }, + ] + : []; + }), + ]; return mergeAlternativeCandidates(candidates) .filter( (candidate) => @@ -802,7 +756,7 @@ function boundedAlternatives( } interface ActionInput { - payload: UnifiedSearchPresentationInput; + searchRef?: string; snapshot: SnapshotFacts | undefined; lifecycle: UnifiedSearchLifecycle; availability: UnifiedSearchAvailability; @@ -813,28 +767,26 @@ interface ActionInput { function projectAction(input: ActionInput): UnifiedSearchAction { if (input.lifecycle.kind === "active") { - const searchRef = extractSearchRef(input.payload); - return searchRef ? { kind: "poll", searchRef } : { kind: "none" }; + return input.searchRef + ? { kind: "poll", searchRef: input.searchRef } + : { kind: "none" }; } if ( input.lifecycle.kind === "terminal" || input.lifecycle.kind === "unknown" ) { - if (input.siteSuggestions.length > 0) { - return { kind: "site_retry" }; - } - return { kind: "new_search" }; + return input.siteSuggestions.length > 0 + ? { kind: "site_retry" } + : { kind: "new_search" }; } if ( input.lifecycle.kind === "completed" && input.snapshot?.evidenceNotice !== undefined ) { - const searchRef = extractSearchRef(input.payload); - if (searchRef) return { kind: "status", searchRef }; + if (input.searchRef) return { kind: "status", searchRef: input.searchRef }; } - if (!input.snapshot || input.availability.kind !== "empty") { + if (!input.snapshot || input.availability.kind !== "empty") return { kind: "none" }; - } const hasIndexing = hasIndexingTrustSignal(input.snapshot.sourceStatus); if (hasIndexing) { @@ -857,7 +809,12 @@ function projectAction(input: ActionInput): UnifiedSearchAction { return { kind: "none" }; } - if (isStandaloneSiteSearch(input.snapshot.sourceStatus)) { + if ( + input.snapshot.sourceStatus?.length && + input.snapshot.sourceStatus.every((entry) => + isSiteTarget(entry.targetLabel, entry), + ) + ) { return { kind: "query_rewrite", rewrites: ["site_shorter_or_broader"], @@ -933,15 +890,6 @@ function hasRestrictiveFilters( ); } -function isStandaloneSiteSearch( - sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, -): boolean { - return Boolean( - sourceStatus?.length && - sourceStatus.every((entry) => isSiteTarget(entry.targetLabel, entry)), - ); -} - function isSiteTarget( target: string, entry: UnifiedSearchSourceStatusPayload, From fa73ef99f9053c3173becb9981573cdd534fa066 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 09:49:13 +0300 Subject: [PATCH 17/46] fix: preserve unified search target context Keep served target attribution for stale hits, package context for lone documentation contributors, and deduplicated site-suggestion groups. Use an exhaustive contributor-state mapping and keep renderer internals private. --- .../src/shared/unified-search-presentation.ts | 15 +++--- .../src/shared/unified-search-text.test.ts | 46 ++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 53 +++++++++---------- 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 7baa9af4..659f6724 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -478,12 +478,13 @@ function sourceState( function contributorState( state: "SEARCHED" | "READY" | "PENDING" | "UNAVAILABLE", ): UnifiedSearchSourceReadiness { - return { + const readiness = { SEARCHED: "searched", READY: "available_not_searched", PENDING: "waiting", UNAVAILABLE: "unavailable", - }[state] as UnifiedSearchSourceReadiness; + } satisfies Record; + return readiness[state]; } function projectTrustLimits( @@ -697,18 +698,16 @@ function projectAlternatives( function mergeAlternativeCandidates( candidates: CandidateSet[], ): CandidateSet[] { - const merged: CandidateSet[] = []; + const merged = new Map(); for (const candidate of candidates) { const key = candidate.target?.replace(/@[^/@]+$/, "") ?? ""; - const existing = merged.find( - (value) => (value.target?.replace(/@[^/@]+$/, "") ?? "") === key, - ); + const existing = merged.get(key); if (existing) { existing.versions.push(...candidate.versions); existing.refs.push(...candidate.refs); existing.suggestedRefs.push(...candidate.suggestedRefs); } else { - merged.push({ + merged.set(key, { target: candidate.target, versions: [...candidate.versions], refs: [...candidate.refs], @@ -716,7 +715,7 @@ function mergeAlternativeCandidates( }); } } - return merged; + return [...merged.values()]; } function boundedAlternatives( diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index f31159e4..b7f4d113 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -377,6 +377,27 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("does not suffix deduplicated site suggestions with a target", () => { + const sourceStatus = [ + source({ + source: "docs", + targetLabel: "site:example.com", + suggestedSiteTargets: ["site:docs.example.com"], + }), + source({ + source: "docs", + targetLabel: "site:example.com", + suggestedSiteTargets: ["site:docs.example.com"], + }), + ]; + const text = renderUnifiedSearchSuccess( + incomplete({ partialResults: false, sourceStatus }), + ); + + expect(text).toContain("Suggested site targets: site:docs.example.com"); + expect(text).not.toContain("Suggested site targets for site:example.com:"); + }); + it("renders site retry guidance for completed and terminal site recovery", () => { const sourceStatus = [ source({ @@ -648,12 +669,37 @@ describe("renderUnifiedSearchSuccess", () => { ), ); + expect(firstLine(text)).toBe("1 result from npm:express@5.1.0"); expect(text.match(/Evidence:/g)).toHaveLength(1); expect(text).toContain( "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", ); }); + it("uses the searched package context for a lone docpack outcome", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:express@5.2.1", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 0, + siteKey: "expressjs.com", + siteUrl: "https://expressjs.com/docs", + }, + ], + }), + ], + }), + ); + + expect(firstLine(text)).toBe("No results returned from npm:express@5.2.1"); + }); + it("turns an evidence notice into one concise mutable-evidence action", () => { const text = renderUnifiedSearchSuccess( completed([], { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index a762f79d..3ae149b6 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -163,18 +163,21 @@ function presentationTarget( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], ): string | undefined { - const targetIdentities = new Set([ + if (!results.length && presentation.targets[0]) { + const target = presentation.targets[0]; + return target.served ?? target.fresh ?? target.requested; + } + const sourceTargets = presentation.sources.flatMap((group) => + group.entries.map((entry) => entry.target), + ); + const identities = [ ...results.map((result) => result.target), - ...presentation.sources.flatMap((group) => - group.entries.map((entry) => entry.contextTarget ?? entry.target), - ), - ]); - if (targetIdentities.size > 1) return undefined; - const hitTarget = results[0]?.target; - if (hitTarget) return hitTarget; - const target = presentation.targets[0]; - if (target) return target.served ?? target.fresh ?? target.requested; - return presentation.sources[0]?.entries[0]?.target; + ...sourceTargets, + ].filter((value): value is string => Boolean(value)); + if (new Set(identities).size > 1) return undefined; + if (results[0]) return results[0].target; + const source = presentation.sources[0]?.entries[0]; + return source?.contextTarget ?? source?.target; } function appendPresentationContext( @@ -241,13 +244,7 @@ function appendPresentationSources( ); if (entries.length === 0) continue; const values = entries.map(({ group, entry }) => - formatSourceReadiness( - group, - entry, - state, - trustLimits, - showTargetContext, - ), + formatSourceReadiness(group, entry, trustLimits, showTargetContext), ); const unique = [...new Set(values)]; lines.push(...wrapText(`${label}: ${unique.join(", ")}`)); @@ -257,7 +254,6 @@ function appendPresentationSources( function formatSourceReadiness( group: UnifiedSearchSourceGroup, entry: UnifiedSearchSourceEntry, - state: UnifiedSearchSourceEntry["state"], trustLimits: UnifiedSearchTrustLimit[], showTargetContext: boolean, ): string { @@ -266,7 +262,7 @@ function formatSourceReadiness( ? (entry.contextTarget ?? entry.target) : undefined; const contextSuffix = contextTarget ? ` for ${contextTarget}` : ""; - if (state === "unavailable") { + if (entry.state === "unavailable") { return `${sourceLabel} (${entry.target})${contextSuffix}`; } const coverage = trustLimits.find( @@ -276,7 +272,7 @@ function formatSourceReadiness( limit.target === entry.target, ); const coverageDetails = coverage ? formatCoverageLimit(coverage) : undefined; - if (state === "searched") { + if (entry.state === "searched") { const identity = group.kind === "code" ? undefined @@ -286,7 +282,7 @@ function formatSourceReadiness( ); return `${sourceLabel}${details.length > 0 ? ` (${details.join("; ")})` : ""}${contextSuffix}`; } - if (state === "waiting") { + if (entry.state === "waiting") { const identity = showTargetContext && group.kind !== "code" ? formatDocumentationSourceIdentity(group, entry) @@ -415,15 +411,16 @@ function appendPresentationSiteSuggestions( presentation: UnifiedSearchPresentation, ): void { const seen = new Set(); - for (const facts of presentation.siteSuggestions) { + const rendered = presentation.siteSuggestions.flatMap((facts) => { const suggestions = facts.suggestions.filter((suggestion) => { if (seen.has(suggestion)) return false; seen.add(suggestion); return true; }); - if (suggestions.length === 0) continue; - const targetSuffix = - presentation.siteSuggestions.length > 1 ? ` for ${facts.target}` : ""; + return suggestions.length > 0 ? [{ facts, suggestions }] : []; + }); + for (const { facts, suggestions } of rendered) { + const targetSuffix = rendered.length > 1 ? ` for ${facts.target}` : ""; lines.push( ...wrapText( `Suggested site targets${targetSuffix}: ${suggestions.join(", ")}`, @@ -563,7 +560,7 @@ export function renderUnifiedSearchError( return lines.join("\n"); } -export function appendUnifiedSearchHits( +function appendUnifiedSearchHits( lines: string[], hits: UnifiedSearchHitPayload[], ): void { @@ -1116,7 +1113,7 @@ export function formatProgressTarget(target: { return parts.length > 0 ? parts.join(SEP) : "target progress unavailable"; } -export function describeFreshness(value: string): string { +function describeFreshness(value: string): string { switch (value) { case "PENDING": return "pending"; From f37df0acfeeecd254c816974bf14382ea0054685 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 09:50:40 +0300 Subject: [PATCH 18/46] refactor: restore alternative merge behavior Remove the unrequested alternative Map rewrite and retain the established array merge while preserving the typed contributor mapping and search-target fixes. --- packages/mcp/src/shared/unified-search-presentation.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 659f6724..b17a1b6f 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -698,16 +698,18 @@ function projectAlternatives( function mergeAlternativeCandidates( candidates: CandidateSet[], ): CandidateSet[] { - const merged = new Map(); + const merged: CandidateSet[] = []; for (const candidate of candidates) { const key = candidate.target?.replace(/@[^/@]+$/, "") ?? ""; - const existing = merged.get(key); + const existing = merged.find( + (value) => (value.target?.replace(/@[^/@]+$/, "") ?? "") === key, + ); if (existing) { existing.versions.push(...candidate.versions); existing.refs.push(...candidate.refs); existing.suggestedRefs.push(...candidate.suggestedRefs); } else { - merged.set(key, { + merged.push({ target: candidate.target, versions: [...candidate.versions], refs: [...candidate.refs], @@ -715,7 +717,7 @@ function mergeAlternativeCandidates( }); } } - return [...merged.values()]; + return merged; } function boundedAlternatives( From 704815a1720c140b80aa9d3d2412b3b4e052f008 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 10:11:33 +0300 Subject: [PATCH 19/46] refactor: separate searched source targets Replace the overloaded source context field with an explicit searched target so provenance identity and package attribution remain distinct across source readiness and result headlines. --- .../unified-search-presentation.test.ts | 19 +++--- .../src/shared/unified-search-presentation.ts | 17 +++--- .../src/shared/unified-search-text.test.ts | 58 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 37 ++++++------ 4 files changed, 97 insertions(+), 34 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 5a18718e..394ce7ba 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -221,6 +221,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "searched", target: "npm:express@4.18.2", + searchTarget: "npm:express@4.18.2", resultCount: 0, }, ], @@ -254,6 +255,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "searched", target: "npm:express@4.18.2", + searchTarget: "npm:express@4.18.2", resultCount: 0, }, ], @@ -284,6 +286,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "unavailable", target: "npm:express@4.18.2", + searchTarget: "npm:express@4.18.2", resultCount: 0, }, ], @@ -310,6 +313,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "searched", target: "npm:express@4.18.2", + searchTarget: "npm:express@4.18.2", resultCount: 0, }, ], @@ -466,7 +470,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "searched", target: "https://github.com/expressjs/express", - contextTarget: "npm:express@5.1.0", + searchTarget: "npm:express@5.1.0", resultCount: 1, repositoryUrl: "https://github.com/expressjs/express", }, @@ -478,14 +482,14 @@ describe("projectUnifiedSearchPresentation", () => { { state: "waiting", target: "expressjs.com", - contextTarget: "npm:express@5.1.0", + searchTarget: "npm:express@5.1.0", resultCount: 0, siteKey: "expressjs.com", }, { state: "available_not_searched", target: "https://api.example.com/reference", - contextTarget: "npm:express@5.1.0", + searchTarget: "npm:express@5.1.0", resultCount: 0, siteKey: "api.example.com", siteUrl: "https://api.example.com/reference", @@ -547,7 +551,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "searched", target: "https://github.com/expressjs/express", - contextTarget: "npm:express@5.1.0", + searchTarget: "npm:express@5.1.0", resultCount: 1, repositoryUrl: "https://github.com/expressjs/express", commitSha: "0123456789abcdef", @@ -560,7 +564,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "searched", target: "expressjs.com", - contextTarget: "npm:express@5.1.0", + searchTarget: "npm:express@5.1.0", resultCount: 1, siteKey: "expressjs.com", }, @@ -779,6 +783,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "waiting", target: "npm:n8n@2.36.7", + searchTarget: "npm:n8n@2.36.7", resultCount: 0, }, ], @@ -789,7 +794,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "available_not_searched", target: "https://n8n.io", - contextTarget: "npm:n8n@2.36.7", + searchTarget: "npm:n8n@2.36.7", resultCount: 0, siteKey: "n8n.io", siteUrl: "https://n8n.io", @@ -802,7 +807,7 @@ describe("projectUnifiedSearchPresentation", () => { { state: "waiting", target: "https://github.com/n8n-io/n8n", - contextTarget: "npm:n8n@2.36.7", + searchTarget: "npm:n8n@2.36.7", resultCount: 0, repositoryUrl: "https://github.com/n8n-io/n8n", }, diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index b17a1b6f..744d464a 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -52,7 +52,7 @@ export type UnifiedSearchSourceReadiness = export interface UnifiedSearchSourceEntry { state: UnifiedSearchSourceReadiness; target: string; - contextTarget?: string; + searchTarget: string; resultCount?: number; repositoryUrl?: string; commitSha?: string; @@ -63,7 +63,7 @@ export interface UnifiedSearchSourceEntry { type SourceIdentity = Pick< UnifiedSearchSourceEntry, | "target" - | "contextTarget" + | "searchTarget" | "repositoryUrl" | "commitSha" | "siteKey" @@ -416,14 +416,14 @@ function contributorIdentity( UnifiedSearchSourceStatusPayload["contributors"] >[number], ): SourceIdentity { - const contextTarget = sourceTarget(entry); + const searchTarget = sourceTarget(entry); const target = contributor.kind === "REPOSITORY_DOCS" - ? (contributor.repositoryUrl ?? contextTarget) - : (contributor.siteUrl ?? contributor.siteKey ?? contextTarget); + ? (contributor.repositoryUrl ?? searchTarget) + : (contributor.siteUrl ?? contributor.siteKey ?? searchTarget); return { target, - ...(target !== contextTarget ? { contextTarget } : {}), + searchTarget, ...(contributor.repositoryUrl ? { repositoryUrl: contributor.repositoryUrl } : {}), @@ -438,9 +438,6 @@ function sourceIdentity( kind: UnifiedSearchSourceKind, ): SourceIdentity { const target = sourceTarget(entry); - const contextTarget = entry.requestedTarget ?? entry.freshTarget; - const context = - contextTarget && contextTarget !== target ? { contextTarget } : {}; const served = entry.targetResolution?.served; const identity = kind === "repository_docs" @@ -451,7 +448,7 @@ function sourceIdentity( : kind === "site_docs" && served?.site ? { siteKey: served.site } : {}; - return { target, ...context, ...identity }; + return { target, searchTarget: target, ...identity }; } function sourceTarget(entry: UnifiedSearchSourceStatusPayload): string { diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index b7f4d113..0a793bb6 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -273,6 +273,26 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.match(/Next:/g)).toHaveLength(1); }); + it("omits a singular target for multiple active progress targets", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 2, + elapsedMs: 100, + targets: [ + { requested: "npm:one@1.0.0", freshness: "INDEXING" }, + { requested: "npm:two@2.0.0", freshness: "INDEXING" }, + ], + }, + }), + ); + + expect(firstLine(text)).toBe("Indexing - no result snapshot returned yet"); + expect(text).toContain("Ready: 0/2 targets"); + }); + it("does not invent source details for a true progress-only response", () => { const text = renderUnifiedSearchSuccess( incomplete({ @@ -700,6 +720,44 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toBe("No results returned from npm:express@5.2.1"); }); + it.each([ + [ + "github:expressjs/express#main", + "github:expressjs/express#main", + "npm:express@5.2.1", + "npm:express@5.1.0", + ], + [ + "npm:express@5.2.1", + "npm:express latest", + "npm:express@5.2.1", + "npm:express@5.1.0", + ], + ] as const)( + "uses the served package for a requested %s source", + (targetLabel, requestedTarget, freshTarget, servedTarget) => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel, + requestedTarget, + freshTarget, + servedTarget, + codeIndexState: "INDEXING", + }), + ], + }), + ); + + expect(firstLine(text)).toBe( + "No results returned from npm:express@5.1.0", + ); + expect(firstLine(text)).not.toContain(targetLabel); + expect(firstLine(text)).not.toContain(freshTarget); + }, + ); + it("turns an evidence notice into one concise mutable-evidence action", () => { const text = renderUnifiedSearchSuccess( completed([], { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 3ae149b6..fed6f3a3 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -163,21 +163,28 @@ function presentationTarget( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], ): string | undefined { - if (!results.length && presentation.targets[0]) { + if (presentation.targets.length > 1) return undefined; + if (results.length > 0) { + const sourceTargets = presentation.sources.flatMap((group) => + group.entries.map((entry) => entry.searchTarget), + ); + const identities = [ + ...results.map((result) => result.target), + ...sourceTargets, + ]; + if (new Set(identities).size > 1) return undefined; + return results[0]?.target; + } + if (presentation.targets.length === 1) { const target = presentation.targets[0]; - return target.served ?? target.fresh ?? target.requested; + return target?.served ?? target?.fresh ?? target?.requested; } const sourceTargets = presentation.sources.flatMap((group) => - group.entries.map((entry) => entry.target), + group.entries.map((entry) => entry.searchTarget), ); - const identities = [ - ...results.map((result) => result.target), - ...sourceTargets, - ].filter((value): value is string => Boolean(value)); - if (new Set(identities).size > 1) return undefined; - if (results[0]) return results[0].target; + if (new Set(sourceTargets).size > 1) return undefined; const source = presentation.sources[0]?.entries[0]; - return source?.contextTarget ?? source?.target; + return source?.searchTarget ?? source?.target; } function appendPresentationContext( @@ -231,9 +238,7 @@ function appendPresentationSources( { state: "unavailable", label: "Unavailable" }, ]; const contextTargets = new Set( - groups.flatMap((group) => - group.entries.map((entry) => entry.contextTarget ?? entry.target), - ), + groups.flatMap((group) => group.entries.map((entry) => entry.searchTarget)), ); const showTargetContext = contextTargets.size > 1; for (const { state, label } of states) { @@ -258,10 +263,8 @@ function formatSourceReadiness( showTargetContext: boolean, ): string { const sourceLabel = sourceGroupLabel(group.kind); - const contextTarget = showTargetContext - ? (entry.contextTarget ?? entry.target) - : undefined; - const contextSuffix = contextTarget ? ` for ${contextTarget}` : ""; + const searchTarget = showTargetContext ? entry.searchTarget : undefined; + const contextSuffix = searchTarget ? ` for ${searchTarget}` : ""; if (entry.state === "unavailable") { return `${sourceLabel} (${entry.target})${contextSuffix}`; } From 53d8eb0af73b0c0609721e429d956ae6dc7c07ef Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 10:27:00 +0300 Subject: [PATCH 20/46] fix: preserve indexing freshness evidence Classify divergent hit snapshots as stale evidence while indexing, and avoid repeating standalone site identities when target context already names them. --- .../src/shared/unified-search-presentation.ts | 2 +- .../src/shared/unified-search-text.test.ts | 49 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 15 +++--- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 744d464a..aa330306 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -520,7 +520,7 @@ function projectTrustLimits( } for (const hit of snapshot?.results ?? []) { - if (hit.freshness === "STALE") { + if (hit.freshness === "STALE" || hit.freshness === "INDEXING") { add({ kind: "stale", target: hit.servedTarget ?? hit.target, diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 0a793bb6..5d0c279e 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -536,6 +536,35 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("does not repeat a standalone site target in its readiness identity", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "code", + targetLabel: "npm:one@1.0.0", + codeIndexState: "INDEXING", + }), + source({ + source: "docs", + targetLabel: "site:docs.one.example", + targetResolution: { + requested: { site: "site:docs.one.example" }, + served: { site: "site:docs.one.example" }, + freshness: "current", + availableVersions: [], + availableRefs: [], + }, + }), + ], + }), + ); + + expect(text).toContain("Waiting: code for npm:one@1.0.0"); + expect(text).toContain("Searched: site docs (site:docs.one.example)"); + expect(text).not.toContain("site docs (site:docs.one.example) for site:"); + }); + it("omits a singular outcome target when hits span multiple targets", () => { const text = renderUnifiedSearchSuccess( completed([ @@ -696,6 +725,26 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("treats indexing hit freshness as stale served evidence", () => { + const text = renderUnifiedSearchSuccess( + completed([ + codeHit({ + target: "npm:express@5.1.0", + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + freshness: "INDEXING", + }), + ]), + ); + + expect(firstLine(text)).toBe("1 result from npm:express@5.1.0"); + expect(text.match(/Evidence:/g)).toHaveLength(1); + expect(text).toContain( + "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", + ); + }); + it("uses the searched package context for a lone docpack outcome", () => { const text = renderUnifiedSearchSuccess( completed([], { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index fed6f3a3..194b38bc 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -263,10 +263,13 @@ function formatSourceReadiness( showTargetContext: boolean, ): string { const sourceLabel = sourceGroupLabel(group.kind); - const searchTarget = showTargetContext ? entry.searchTarget : undefined; - const contextSuffix = searchTarget ? ` for ${searchTarget}` : ""; + const contextSuffix = (identity?: string): string => + showTargetContext && + (group.kind === "code" || identity !== entry.searchTarget) + ? ` for ${entry.searchTarget}` + : ""; if (entry.state === "unavailable") { - return `${sourceLabel} (${entry.target})${contextSuffix}`; + return `${sourceLabel} (${entry.target})${contextSuffix(entry.target)}`; } const coverage = trustLimits.find( (limit): limit is Extract => @@ -283,20 +286,20 @@ function formatSourceReadiness( const details = [identity, coverageDetails].filter( (value): value is string => Boolean(value), ); - return `${sourceLabel}${details.length > 0 ? ` (${details.join("; ")})` : ""}${contextSuffix}`; + return `${sourceLabel}${details.length > 0 ? ` (${details.join("; ")})` : ""}${contextSuffix(identity)}`; } if (entry.state === "waiting") { const identity = showTargetContext && group.kind !== "code" ? formatDocumentationSourceIdentity(group, entry) : undefined; - return `${sourceLabel}${identity ? ` (${identity})` : ""}${contextSuffix}`; + return `${sourceLabel}${identity ? ` (${identity})` : ""}${contextSuffix(identity)}`; } const identity = group.kind === "site_docs" ? `${formatDocumentationSourceIdentity(group, entry)} docs` : `${sourceLabel} (${entry.target})`; - return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix}`; + return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix(identity)}`; } function formatDocumentationSourceIdentity( From 53c710d4b24aac09069333190e77f4c01d7a3c5e Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 10:32:33 +0300 Subject: [PATCH 21/46] fix: avoid duplicate readiness targets Use exact undecorated source identities when deciding multi-target suffixes, preserving disambiguation for code and distinct documentation provenance. --- .../src/shared/unified-search-text.test.ts | 41 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 13 +++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 5d0c279e..c50a6c4a 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -565,6 +565,47 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("site docs (site:docs.one.example) for site:"); }); + it("does not repeat exact identities for unavailable code or available sites", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:one@1.0.0", + codeIndexState: "MISSING", + }), + source({ + targetLabel: "npm:two@2.0.0", + codeIndexState: "CURRENT", + }), + source({ + source: "docs", + targetLabel: "site:docs.one.example", + contributors: [ + { + kind: "DOCPACK", + state: "READY", + resultCount: 0, + siteKey: "site:docs.one.example", + }, + ], + }), + ], + }), + ); + + expect(text).toContain("Unavailable: code (npm:one@1.0.0)"); + expect(text).not.toContain( + "Unavailable: code (npm:one@1.0.0) for npm:one@1.0.0", + ); + expect(text).toContain("Searched: code for npm:two@2.0.0"); + expect(text).toContain( + "Available but not searched: site:docs.one.example docs", + ); + expect(text).not.toMatch( + /Available but not searched: site:docs\.one\.example docs for\s+site:docs\.one\.example/, + ); + }); + it("omits a singular outcome target when hits span multiple targets", () => { const text = renderUnifiedSearchSuccess( completed([ diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 194b38bc..335370f3 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -264,8 +264,7 @@ function formatSourceReadiness( ): string { const sourceLabel = sourceGroupLabel(group.kind); const contextSuffix = (identity?: string): string => - showTargetContext && - (group.kind === "code" || identity !== entry.searchTarget) + showTargetContext && identity !== entry.searchTarget ? ` for ${entry.searchTarget}` : ""; if (entry.state === "unavailable") { @@ -295,11 +294,15 @@ function formatSourceReadiness( : undefined; return `${sourceLabel}${identity ? ` (${identity})` : ""}${contextSuffix(identity)}`; } + const baseIdentity = + group.kind === "site_docs" + ? formatDocumentationSourceIdentity(group, entry) + : entry.target; const identity = group.kind === "site_docs" - ? `${formatDocumentationSourceIdentity(group, entry)} docs` - : `${sourceLabel} (${entry.target})`; - return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix(identity)}`; + ? `${baseIdentity} docs` + : `${sourceLabel} (${baseIdentity})`; + return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix(baseIdentity)}`; } function formatDocumentationSourceIdentity( From 3dcb5cce0510b015546ccb7bba725b979a63468b Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 10:41:39 +0300 Subject: [PATCH 22/46] docs: record completed search output phase Bring the search output plan in line with the verified Phase 1a implementation, evidence, review decisions, and remaining Phase 1b and Phase 2 work. --- docs/plans/search-output-ux.md | 665 +++++++++++++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 docs/plans/search-output-ux.md diff --git a/docs/plans/search-output-ux.md b/docs/plans/search-output-ux.md new file mode 100644 index 00000000..a2259d18 --- /dev/null +++ b/docs/plans/search-output-ux.md @@ -0,0 +1,665 @@ +# Plan: Search output information hierarchy + +## Status + +- Overall: **IN PROGRESS** +- Phase 1a: **COMPLETE** (implemented, verified, and clean-reviewed; awaiting + draft PR/merge) +- Phase 1b: **PENDING ON PHASE 1A** +- Phase 2: **PENDING** + +## Problem and expected outcome + +`githits search` and `githits search-status` currently expose the same indexing +state through warnings, progress fields, target-resolution prose, source notes, +documentation-contributor notes, and an evidence notice. The renderers append +those independent projections instead of deciding which facts the reader needs. +The result is repetitive, hard to scan, wider than the terminal, and especially +expensive in default MCP text output. + +When this work is complete: + +- the first line states what the command returned and whether indexing continues; +- each lifecycle, freshness, coverage, and continuation fact appears once; +- progress and source readiness are expressed in user terms instead of internal + reason codes and duplicated target identities; +- CLI color reinforces the information hierarchy without carrying meaning by + itself; +- CLI human output and MCP `text-v1` use the same semantic projection while + retaining surface-native actions; +- JSON remains the complete structured/debug representation; +- the same terminal hierarchy is applied to other high-information commands + where the follow-up audit proves equivalent problems. + +## Verified current state and evidence + +1. The reported `npm:n8n` response says indexing is active at least four ways: + three promoted warnings, the `Indexing/search still in progress` headline, + `status: indexing`, the target `state=pending`, source details, and the backend + evidence notice. +2. `formatProgressTarget()` repeats requested/fresh identities, `indexingRef`, + target-resolution notes, freshness reason, and indexed alternatives on one + unbounded line. In the supplied screenshot, that line exceeds the viewport. +3. `formatUnifiedSearchTerminal()` in `src/commands/search.ts` independently + concatenates warnings, progress, source status, documentation contributors, + and the evidence notice. `renderUnifiedSearchSuccess()` and + `renderUnifiedSearchStatusText()` build a second, different narrative for MCP. + No layer owns prioritization or cross-section deduplication. +4. Indexing and target-resolution conditions are promoted into top-level + `warnings[]` for structured callers. CLI and MCP text then render those warnings + alongside the structured progress/source facts that generated them. +5. The core service already receives `UnifiedSearchResult.partialResults`, but + `buildUnifiedSearchSuccessPayload()` and + `buildUnifiedSearchStatusResultPayload()` drop it. The CLI consequently labels + every incomplete result set `Partial results`, including atomic interim evidence + for which `partialResults` is false. +6. Search CLI status headlines and next actions are unstyled while result targets + and locations receive bold cyan emphasis and almost all provenance is dimmed. + The most important decision points therefore have less visual priority than + incidental identifiers. +7. The repository documents MCP `text-v1` as a public format, but the user has + explicitly decided that it is not a compatibility boundary for this redesign. + `text-v1` will be improved in place; no `text-v2` or legacy renderer is needed. +8. Phase 1a is implemented: the additive `partialResults` JSON field, one shared + presentation projection, outcome-first MCP `text-v1` rendering, tool/parity + assertions, and MCP smoke invariants are complete. The model's source-entry + boundary now uses required `searchTarget` for the searched package context; + the overloaded `contextTarget` is gone. Requested/fresh/served divergence is + retained only in progress and trust facts. +9. The final repository evidence is recorded below. Phase 1a did not change CLI + human/color rendering; the reported screenshot/color problem remains Phase 1b + by design. + +### Final Phase 1a evidence + +- `bun test`: 3,329 tests passed, 0 failed, 10,684 expects across 182 files. +- `bun run typecheck`, `bun run format:check`, and `bun run lint`: clean. +- Root and `packages/mcp` builds passed. +- `bun run validate:packages` and `bun run validate:packages:mcp-publish` passed. +- Source `bun run smoke:cli` and `bun run smoke:mcp` passed. +- Targeted `unified-search-investigation` agent E2E succeeded with both Claude + and Codex; usefulness was helped/high confidence. The discovered symbol-label + bug was fixed. +- The final focused shared/status/tool cohort passed 123 tests with 0 failures. +- Production/shared-smoke delta across the five counted source files is 1,456 + additions and 449 deletions. The original 1,450-line Phase 1a plan ceiling + was intentionally exceeded by six lines with explicit user authorization for + the root-cause boundary correction, remaining below the repository's 1.5k + implementation-code caution threshold. +- Built smoke suites were not required: smoke launch and CI product-validation + behavior did not change. + +## Scope + +### Phase 1a and 1b scope + +- CLI `search` and `search-status` human output for completed, active, terminal, + unknown, empty, interim, partial, stale, provisional, and capped-coverage states. +- MCP `search` and `search_status` default `text-v1` output for the same states. +- Shared response projection needed to distinguish actual partial evidence from + atomic interim evidence. +- Search-specific use of existing terminal colors and any smallest shared semantic + color helpers required to express the hierarchy cleanly. +- Search CLI/MCP documentation, smoke coverage, qualitative agent evaluation, and + release fragment. + +### Phase 2 scope + +- Other user-facing terminal formatters that the post-Phase-1 audit proves violate + the same hierarchy: primary outcome first, actionable state at full intensity, + muted detail only for optional provenance, and semantic severity colors. +- Permanent cross-command terminal-output guidance once the roles have been proven + by the search implementation. + +### Non-goals + +- Backend lifecycle, indexing, ranking, or evidence semantics. +- Changing search defaults, polling behavior, retry rules, or partial-result policy. +- Removing structured fields from JSON or hiding diagnostic data from `--json` / + `format: "json"`. +- A general rendering framework, theme engine, layout DSL, output mode, or new CLI + flag. +- Rewording unrelated command results during Phase 1. +- Using color as the only indication of state. +- Changing raw source/document content rendering; the existing terminal-text + sanitization plan owns that separate trust boundary. +- Changing search error-envelope shape or error semantics. Existing CLI and MCP + error rendering remains unchanged in Phase 1. + +## Target architecture + +### Ownership + +The shared search presentation layer owns the meaning and priority of response +facts. The response builder continues to own lossless structured projection. The +CLI and MCP renderers own syntax, color, and surface-native actions only. + +```text +Core UnifiedSearchOutcome + | + v +shared JSON payload builder ----> CLI --json / MCP format=json + | + v +shared search presentation model + | | + v v +CLI terminal renderer MCP text-v1 renderer +``` + +This corrects the current ownership friction: CLI and MCP both need to know whether +evidence is absent, interim, partial, final, stale, or provisional, but neither +renderer should rediscover that from warning strings. + +### Presentation model + +Add one pure shared projection that derives four independent dimensions from the +typed payload: + +- **availability**: no snapshot, empty snapshot, interim results, partial results, + or final results; +- **lifecycle**: the exact active status (`PENDING`, `INDEXING`, or `SEARCHING`), + completed, the exact terminal status (`DEFERRED`, `TIMEOUT`, or `FAILED`), or + an unrecognized raw status; +- **trust limits**: older snapshot, provisional index, pending/unsearched source, + incomplete/capped coverage, ignored or incompatible query constraints; +- **action**: poll the current reference, start a later search, change the query or + source, use an indexed alternative, or none. + +The projection must consume structured fields. It must not parse promoted warning +prose. Promoted warnings remain available in JSON, while text renderers show only +query/filter/source problems not already represented by the lifecycle and trust +dimensions. + +The model contains display facts, not finished sentences or ANSI codes. It retains +the exact target/source identities and continuation reference needed by renderers, +but omits internal-only `freshnessReason`, `requestedRefKind`, and `indexingRef` from +default text unless one becomes a verified user action. Those values remain in JSON. + +The source-entry boundary is explicit: `searchTarget` names the searched +package/target context, while `target` remains the served or contributor identity. +The former overloaded `contextTarget` is not used. Requested/fresh/served divergence +lives in progress and trust facts, so it cannot accidentally rename a result based +on contributor or docpack identity. + +### Structured contract correction + +Preserve the backend's actual `partialResults` Boolean on initial search payloads +and stored status results. Both renderers use it to distinguish: + +- `N interim results returned` when an active response contains an atomic + serveable snapshot (`partialResults: false`); and +- `N partial results returned` only when the backend says the snapshot is a subset + (`partialResults: true`). + +This is an additive JSON field. No GraphQL/API selection change is needed because +both search queries already select `partialResults` and core already validates it. + +### Information hierarchy + +Every human/agent text response follows this order: + +1. **Outcome headline** — what was returned and whether work continues. +2. **Progress/trust summary** — only facts needed to interpret that outcome. +3. **Results**, when any were returned. +4. **Bounded secondary provenance/alternatives**, only when actionable or needed to + qualify the evidence. +5. **One next action**, when applicable. + +Rules: + +- Active output starts with the exact work state, never with warnings: + `Preparing`, `Indexing`, or `Searching` for `PENDING`, `INDEXING`, or `SEARCHING`. +- Do not say `No hits` when no result snapshot was searched; say no results were + returned yet. +- Do not print `status: indexing` after an indexing headline. +- Print `searchRef` only inside the exact next action in human and MCP text. +- Do not print `indexingRef` in default text. +- Collapse requested/fresh/served identities to the one identity that changes the + user's interpretation. Explain divergence once in plain language. +- Group readiness by user-facing evidence source (`code`, `repository docs`, site + docs), not by raw source-status rows. +- State `available but not searched` distinctly from `waiting` and `searched`. +- Treat `evidenceNotice` presence as one concise text-level trust signal that the + disclosed evidence or ordering may change. Do not parse or reproduce its opaque + prose in default text. Preserve the verbatim notice in JSON. +- Bound alternatives in text by category: show at most three versions and three + refs, then `+N more`; JSON remains complete. +- Keep each status/provenance line bounded and independently wrappable. Never join + the complete target diagnostic record with ` | `. +- Query/filter incompatibilities remain visible once, below the outcome headline. +- MCP active responses retain `Do not repeat search.` before the exact status action. + Completed empty responses retain `Do not repeat this search unchanged.`, and + evidence-limited responses retain `Do not repeat immediately.` Terminal responses + retain the existing prohibition on polling a stopped reference. The CLI drops the + anti-repeat directives it currently inherits from shared empty-search guidance; + those guardrails are agent-specific and remain in MCP text only. + +The action dimension also preserves the existing empty-result pivot rules: + +1. evidence-limited or unsearched-source results suppress generic query pivots; +2. indexing/provisional results suggest waiting or an indexed alternative, not query + rewriting; +3. standalone site searches suggest only a shorter/broader site query and never + another source or `code_grep`; +4. removing filters or switching to symbol search is suggested only when those pivots + apply to the actual request. + +### Phase 1b target: CLI shape for the reported active empty snapshot + +```text +Indexing npm:n8n@2.36.7 — no results returned yet +Ready: 0/1 targets +Waiting: code, repository docs +Available but not searched: n8n.io docs (1,480 pages; capped) +Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2; refs HEAD, master + +Next: githits search-status fabUr1S3MEVeSgD93pMoSQ --wait 20 +``` + +The supplied text proves this response contains an empty result snapshot with +`sourceStatus` and documentation contributors: contributor identity, readiness, and +page counts cannot come from progress alone. The regression fixture will encode the +disclosed structured facts from the supplied output; it will not depend on reproducing +the transient production indexing state with a fresh network call. + +This remains a Phase 1b target. Phase 1a did not change CLI human/color rendering; +the exact copy may tighten during Phase 1b implementation tests, but the section +order, single-statement rules, disclosed evidence distinctions, and bounded +alternatives are acceptance constraints. + +A true progress-only CLI response has no `sourceStatus` or documentation contributors +and therefore renders only derivable facts: + +```text +Indexing npm:n8n@2.36.7 — no result snapshot returned yet +Ready: 0/1 targets +Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2; refs HEAD, master + +Next: githits search-status --wait 20 +``` + +It must not synthesize per-source waiting state, site identity, or page coverage. + +### Other response shapes + +```text +Indexing continues — 4 interim results returned +Ready: 1/2 targets · results and ranking may change + + + +Next: githits search-status --wait 20 +``` + +```text +Indexing continues — 4 partial results returned +Ready: 1/2 targets · some requested sources are not represented + + + +Next: githits search-status --wait 20 +``` + +```text +10 results from npm:n8n@2.26.9 +Latest npm:n8n@2.36.7 is still indexing; these results use the older snapshot. + + +``` + +Completed current results retain the existing result blocks but use a concise count +headline and at most one source-provenance line before the hits. Completed empty +results state which evidence was actually searched before suggesting one applicable +pivot. Terminal and unknown states preserve disclosed evidence without inventing +indexing, completion, or absence claims. + +### Color semantics + +Phase 1 uses a small semantic mapping: + +- active indexing / degraded-but-usable headline: bold yellow; +- failed terminal headline: bold red; +- completed result count and primary result identity: bold neutral; +- exact next command: cyan or bold cyan; +- backend match spans: existing bold yellow; +- optional provenance, bounded-alternative remainder, and secondary metadata: dim; +- warnings that require a user decision: full-intensity yellow, never dim. + +No-color output keeps identical wording, order, spacing, labels, and glyph-independent +meaning. Do not color entire status paragraphs or whole result locations merely because +they are identifiers. + +## Assumptions and unknowns + +### Overall assumptions + +1. Search/search-status is split into two independently reviewable increments. Phase + 1a completed shared/MCP output and structured truth; Phase 1b completes the + reported CLI production fix after Phase 1a merges. Splitting delivery does not + shrink the overall goal. +2. `text-v1` may change in place, per the user's explicit decision on 2026-08-26. +3. JSON is the correct place for full diagnostic identities, reason codes, + indexing references, and unbounded alternatives. +4. Existing lifecycle statuses and conservative handling of unknown statuses remain + authoritative. +5. The current backend fields are sufficient for Phase 1; no new service call or + backend change is required. +6. The implemented source-entry boundary uses `searchTarget` for searched package + context and keeps requested/fresh/served divergence in progress/trust facts. + +### Overall unknowns + +- The exact Phase 2 command cohort. Resolve at the Phase 1 boundary by comparing + representative no-color/color output from every formatter that uses shared color + helpers against the proven hierarchy. This does not block Phase 1. +- Whether permanent terminal-output guidance belongs in a new focused implementation + document or an existing CLI document. Resolve during Phase 2 reorientation based on + the size of the proven cross-command contract. + +### Open product decisions + +None for Phases 1a and 1b. + +### Resolved product decisions + +- Improve MCP `text-v1` in place; do not add a versioned compatibility branch. +- Default human/MCP text may summarize opaque `evidenceNotice` prose as one generic + trust limitation. Exact backend prose remains available in JSON. This deliberately + makes default text lossy to remove the reported token-heavy boilerplate while still + stating that returned evidence or ordering may change. + +## Cross-cutting considerations + +### Compatibility and migration + +- CLI human text intentionally changes; `--json` remains the automation boundary. +- MCP `text-v1` intentionally changes in place by user decision. Tool schemas and + default format names remain unchanged. +- `partialResults` is added to structured initial and status-result JSON. Existing + fields retain their meaning. +- Search and search-status must remain behaviorally aligned for the same stored + result and lifecycle state. + +### Security + +This work must not copy or expose credentials. It does not add network calls. New +formatting must follow the separate terminal-text sanitization plan when that shared +helper becomes available; Phase 1 does not absorb the broader sanitization effort. + +### Performance + +The presentation projection is a linear pass over already-bounded targets, source +statuses, contributors, warnings, and results. No benchmark is required because this +is not an optimization and adds no I/O, cache, or repeated search. Avoid sorting large +backend collections; preserve backend order and cap only display projection. + +### Release boundary + +Phase 1a changed MCP default text and added `partialResults` to both MCP JSON and +root CLI `--json`. Its independent fragment uses `githits: patch` and +`@githits/mcp: patch`. Patch is appropriate because this corrects +misleading/duplicated output within the current minor and adds one structured truth +field without removing or redefining existing fields. The 0.11.0 precedent is not +comparable: it added public `quick_start`/configuration APIs and a deprecation path; +the closer 0.6.4 agent-facing search/recovery change was a patch. Retain patch/patch. + +Phase 1b changes CLI terminal presentation only, so add a separate fragment with +`githits: patch` and `@githits/mcp: none` unless implementation evidence shows it also +changes MCP package behavior. Do not edit `CHANGELOG.md` or package versions outside +release preparation. + +Phase 2 will add its own fragment. Expected impact is `githits: patch` and +`@githits/mcp: none` if it changes only CLI ANSI styling; re-evaluate if shared MCP +text changes. + +### Documentation + +- Update `docs/implementation/cli-commands.md` with the outcome-first search family + contract and concise examples. +- Update `docs/implementation/tools.md` with the revised in-place `text-v1` anatomy. +- Update `docs/implementation/mcp-cli-parity.md` to state that search shares semantic + projection while rendering surface-native commands. +- Update MCP tool/instruction copy only where it describes the old output anatomy. +- Phase 2 records durable cross-command semantic color rules after they are proven. + +## Phase map + +### Phase 1a — Shared semantics and MCP text become outcome-first + +- Status: **COMPLETE** (implemented, verified, and clean-reviewed; awaiting draft + PR/merge) +- Delivered: structured search payloads preserve actual partialness, one pure model + owns lifecycle/availability/trust/action decisions, and MCP `text-v1` clearly + states what was returned without duplicate lifecycle prose. Source provenance + keeps explicit searched-target context separate from served/contributor identity. +- Verification: see `Final Phase 1a evidence` above. No major Phase 1a item is + deferred and no Phase 1a TODO remains. +- Next dependency: Phase 1b starts after this phase merges and reorientation checks + the shared model against current `origin/main`. + +### Phase 1b — CLI search output gains the same hierarchy and useful color + +- Status: **PENDING ON PHASE 1A** +- Expected outcome: the reported CLI case fits in one short screenful, full/interim/ + partial states use the shared decisions, and color emphasizes status and action. +- Assumptions: Phase 1a's model is sufficient without CLI-only semantic fields. +- Unknowns or product decisions: none. +- Dependencies: Phase 1a merged and reorientation confirms the shared model contract. +- Acceptance criteria: the CLI portions of the detailed criteria below. + +### Phase 2 — Proven terminal hierarchy becomes consistent across commands + +- Status: **PENDING** +- Expected outcome: other high-information CLI commands with verified hierarchy or + color-role problems use the same semantic roles without unrelated copy redesign. +- Assumptions: Phase 1 establishes usable roles and test patterns; Phase 2 remains a + separate increment to contain review scope. +- Unknowns or product decisions: exact formatter cohort and durable documentation + location, resolved at phase-boundary reorientation. +- Dependencies: Phase 1 merged and reorientation against current `origin/main`. +- Acceptance criteria: + - every migrated command has an outcome-first first screenful; + - warnings/actions are not dimmed and colors follow the documented roles; + - no-color output conveys the same state and action; + - unchanged commands are explicitly shown not to violate the proven rules; + - no general theme/rendering infrastructure is introduced. + +## Phase 1a and 1b detailed implementation plan + +### Expected outcome + +Phase 1a delivered correct, compact MCP text and structured JSON. Phase 1b remains +responsible for making the reported CLI case fit in one short screenful. Interim, +actual partial, completed, stale/provisional, terminal, and unknown cases use the +same hierarchy. CLI and MCP text cannot independently reintroduce duplicate +lifecycle prose because they consume one shared semantic projection. + +### Likely affected components + +- `packages/mcp/src/shared/unified-search-response.ts` +- new `packages/mcp/src/shared/unified-search-presentation.ts` +- `packages/mcp/src/shared/unified-search-text.ts` +- `packages/mcp/src/shared/unified-search-status-text.ts` +- `packages/mcp/src/shared/target-resolution.ts` only if display facts must be split + from current prose helpers +- `packages/mcp/src/internal.ts` +- `src/commands/search.ts` +- likely new `src/commands/search-terminal.ts` +- search-specific semantic color wrappers in `src/commands/search-terminal.ts`; keep + CLI-only roles out of the MCP package and touch `packages/mcp/src/shared/colors.ts` + only if an existing primitive is genuinely missing +- colocated response, presentation, renderer, tool, command, parity, smoke, and color + tests +- implementation documentation and the phase-specific changes fragments + +### Ordered implementation + +#### Phase 1a — structured truth, presentation model, and MCP text (complete; do not repeat) + +The numbered execution list is superseded by the completed implementation. Phase 1a +added the additive `partialResults` field, the pure presentation projection, and the +MCP `text-v1` search/status renderers; migrated tool/parity tests and MCP smoke +invariants; updated permanent docs and the patch/patch release fragment; and passed +the final verification and agent evaluation recorded above. The final corrective +boundary uses `searchTarget` for searched package context, keeps `target` as served or +contributor identity, and retains requested/fresh/served divergence only in progress +and trust facts. No further Phase 1a execution is pending. + +#### Phase 1b — CLI renderer and color hierarchy + +1. Extract the CLI terminal formatter from command orchestration so tests can pass an + explicit color mode and terminal width without mocking I/O. Render the same model + with CLI-native commands and the defined color hierarchy. Keep `searchAction()` and + `searchStatusAction()` responsible only for request orchestration, JSON selection, + and printing. +2. Add structural CLI tests for section order, single occurrence of each state fact, + bounded alternatives, no-result versus no-snapshot wording, terminal handling, + color roles, `NO_COLOR`, and narrow terminals. Avoid broad brittle snapshots. +3. Add one table-driven decision-parity test that feeds the same state fixtures through + both renderer adapters, asserting agreement on availability, exact lifecycle, + trust-limit set, and action kind/reference while allowing surface-native prose and + command syntax. +4. Strengthen CLI smoke with structural assertions that cannot pass merely because the + output contains `search`: the first non-spinner line is an outcome headline, a + lifecycle headline is not followed by a duplicate `status:` line, and any + `searchRef` appears only in the `Next:` action. +5. Update permanent CLI documentation and add the Phase 1b release fragment. Do not + edit versions, generated plugin assets, or changelogs. +6. Run the verification suite and inspect the actual color/no-color output for the + supplied regression fixture at representative narrow and wide terminal widths. +7. Run targeted agent evaluation for search lifecycle workloads and inspect + `tool-calls.json` and `final.json` for continuation accuracy, duplicate guidance, + token use, `toolIssues`, and `instructionIssues`. + +### Edge cases and failure behavior + +- Missing `progress`: state what is known without inventing indexing details; an active + reference can still supply the exact next action. +- Progress without a result/source status: render target readiness and target-level + alternatives only; never synthesize evidence sources, contributor readiness, site + identity, or page counts. +- Unknown lifecycle status: print the raw status once, preserve evidence, do not label + it active/terminal, and do not poll the same reference. +- `DEFERRED`, `FAILED`, and `TIMEOUT`: never emit search-status polling guidance. +- Incomplete response with results and `partialResults: false`: call results interim, + not partial. +- Incomplete response with `partialResults: true`: explicitly state that requested + evidence is missing. +- Completed response with evidence notice/search reference: state results are returned + and may change, then emit one continuation action. +- Available-but-unsearched docs contributor: never describe it as searched or pending. +- Capped/partial docs coverage: disclose evidence limits without calling them indexing + progress or suggesting a wait. +- Stale/provisional/fallback results: identify the served evidence once and keep + follow-up locators pinned to it. +- Multiple targets: retain labels only where needed for disambiguation; do not repeat + the same requested/fresh identity per source. +- Site suggestions: preserve backend order, truncation signal, and explicit retry + labels without automatic selection. +- Long alternatives/targets: cap display and wrap by terminal cells; never truncate the + exact next command or result follow-up. +- Color-disabled/non-TTY output: identical words and layout, no ANSI. + +### Phase 1a and 1b acceptance criteria + +The MCP/JSON criteria below are verified by Phase 1a. Criteria explicitly naming +CLI terminal output, color, or CLI smoke remain pending Phase 1b. + +- The n8n-shaped active empty-snapshot CLI fixture starts with indexing, contains one + readiness summary, distinguishes waiting from available-but-unsearched evidence, + omits raw reason codes and `indexingRef`, bounds alternatives, and ends with one + exact status command. +- A progress-only fixture emits only the lifecycle headline, target readiness, + target-level alternatives when present, and one next action; it does not invent + source or contributor details. +- No lifecycle/freshness fact appears in more than one human/MCP text section. +- `PENDING`, `INDEXING`, and `SEARCHING` produce distinct preparing, indexing, and + searching headlines; terminal and unrecognized raw statuses likewise remain + distinct and are never collapsed before rendering. +- The model classifies every result-bearing response as final, interim, or partial from + lifecycle plus `partialResults`; rendered copy never calls an interim snapshot final + or an atomic interim snapshot partial. +- No-snapshot states never claim zero hits; completed empty snapshots never imply + sources were searched when they were not. +- CLI and MCP text share the same availability/lifecycle/trust/action decisions and + differ only in human styling, result anatomy, and command syntax. +- One table-driven dual-renderer test enforces those shared decisions for every state + fixture rather than relying only on separate renderer suites. +- `--json` and `format: "json"` remain equal and add the exact `partialResults` Boolean; + full diagnostic fields and alternative lists remain available. +- Active states have one continuation action; terminal/unknown states obey existing + conservative no-polling rules. +- MCP text retains the three documented anti-repeat directives and all four conditional + empty-result pivot-suppression rules. +- CLI status hierarchy remains readable with colors disabled and at narrow width. +- Explicit tests cover all listed states and the existing targeted baseline remains + green after updated expectations. +- Required unit, parity, smoke, build, package-validation, and qualitative agent checks + pass or any environment-only limitation is reported with exact evidence. +- CLI smoke structurally verifies the outcome-first headline, absence of duplicate + `status:` prose, and single action-contained `searchRef` when continuation exists. +- Permanent docs and each increment's independent changes fragment match implemented + behavior. + +### Verification + +Run at minimum: + +```text +bun test +bun test +bun run typecheck +bun run format:check +bun run lint +bun run build +(cd packages/mcp && bun run build) +bun run validate:packages +bun run validate:packages:mcp-publish +bun run smoke:cli +bun run smoke:mcp +``` + +Run targeted `bun run agent:e2e` search lifecycle workloads. Use both Claude and Codex +when practical because default agent text and continuation guidance change broadly. +Built smoke suites are required only if smoke launch behavior or built-product CI +validation changes; otherwise source smoke plus both package builds/validators are the +proportionate gates. + +## Phase-boundary reorientation + +After Phase 1a merges, re-check current `origin/main` and reorient Phase 1b. Confirm +the shared presentation contract requires no CLI-only semantic additions; export the +presentation model through `@githits/mcp/internal` only if the CLI renderer needs it. +After Phase 1b merges, run `$next-steps` before detailing Phase 2. Record observed color/no-color +output, accepted/rejected UX rules, test/eval evidence, and any command-specific +exceptions. Then inventory the remaining formatter call sites using those proven rules, +select the smallest coherent command cohort, and add exact files and test tactics for +Phase 2. Do not continue from a stale Phase 2 outline if the search roles did not +generalize cleanly. + +## Completion and plan cleanup + +The overall effort is complete when Phases 1a, 1b, and 2 meet their acceptance criteria, +permanent implementation documentation owns the resulting search and terminal-output +contracts, all required release fragments exist, and no temporary design decision +remains only in this plan. Then delete this plan. If Phase 2 is explicitly removed from +scope, transfer the verified Phase 1a/1b contract to permanent docs and delete the plan +after Phase 1b rather than retaining a stale future-work artifact. + +## Review record + +- Internal technical review: findings covering exact active statuses, explicit + evidence-notice lossiness, and duplicate coverage were accepted and fixed. +- Luna preflight findings on bounded summary wrapping and `hasMore` ownership were + fixed. The initial Opus loop findings were also fixed; that loop exposed the + overloaded source-target identity later corrected by the explicit `searchTarget` + boundary. +- The user selected that root-cause boundary correction. A fresh follow-up Opus loop + ended clean with no findings and nothing deferred. +- Repository policy prevented a second internal `code_reviewer`: this session had + already used its one allowed reviewer for the technical plan. +- Rejected remedy: do not issue a fresh live search to capture transient JSON. The + original indexing state may no longer exist; code inspection proves contributor + details require a result/source-status snapshot, and the regression fixture can + encode every fact disclosed in the supplied output without a network call. From a57d44db7270d0109a20d769d6acf54283fd5766 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 10:47:36 +0300 Subject: [PATCH 23/46] docs: record integrated search verification Update the search output plan with merged-main test, build, package, smoke, and production-delta evidence while preserving the completed Phase 1a and pending Phase 1b/2 status. --- docs/plans/search-output-ux.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/plans/search-output-ux.md b/docs/plans/search-output-ux.md index a2259d18..17ffeda2 100644 --- a/docs/plans/search-output-ux.md +++ b/docs/plans/search-output-ux.md @@ -70,13 +70,15 @@ When this work is complete: human/color rendering; the reported screenshot/color problem remains Phase 1b by design. -### Final Phase 1a evidence - -- `bun test`: 3,329 tests passed, 0 failed, 10,684 expects across 182 files. -- `bun run typecheck`, `bun run format:check`, and `bun run lint`: clean. -- Root and `packages/mcp` builds passed. -- `bun run validate:packages` and `bun run validate:packages:mcp-publish` passed. -- Source `bun run smoke:cli` and `bun run smoke:mcp` passed. +### Final integrated Phase 1a evidence + +- `bun test`: 3,364 tests passed, 0 failed, 10,825 expects across 184 files. +- `bun run typecheck`: clean; format and lint checked 437 files clean. +- Root and `packages/mcp` builds passed on merged `origin/main`. +- `bun run validate:packages` and `bun run validate:packages:mcp-publish` passed; + the publish dry-run was skipped because `@githits/mcp@0.11.0` is already + published. +- Source `bun run smoke:cli` and `bun run smoke:mcp` passed on merged `origin/main`. - Targeted `unified-search-investigation` agent E2E succeeded with both Claude and Codex; usefulness was helped/high confidence. The discovered symbol-label bug was fixed. @@ -86,6 +88,9 @@ When this work is complete: was intentionally exceeded by six lines with explicit user authorization for the root-cause boundary correction, remaining below the repository's 1.5k implementation-code caution threshold. +- `origin/main` at `739ec4e` was merged cleanly with no conflicts. Overlapping + permanent documentation auto-merged, and integrated full-test, build, package, + and source-smoke verification passed. - Built smoke suites were not required: smoke launch and CI product-validation behavior did not change. From 911f67d0cf69edf7788fd015622c9ad1052cec52 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:14:57 +0300 Subject: [PATCH 24/46] refactor: share search text across CLI and MCP Route CLI search and search-status through the shared outcome-first renderer with ANSI and command-dialect options. Remove the duplicated CLI formatter and keep CLI follow-up actions directly executable. --- .../mcp/src/shared/follow-up-command-text.ts | 56 +- .../shared/unified-search-status-text.test.ts | 6 +- .../src/shared/unified-search-status-text.ts | 22 +- .../src/shared/unified-search-text.test.ts | 75 +- .../mcp/src/shared/unified-search-text.ts | 230 ++++- packages/mcp/src/tools/search-status.test.ts | 8 +- src/commands/search.test.ts | 336 ++++--- src/commands/search.ts | 828 +----------------- 8 files changed, 566 insertions(+), 995 deletions(-) diff --git a/packages/mcp/src/shared/follow-up-command-text.ts b/packages/mcp/src/shared/follow-up-command-text.ts index 675a2ea1..77dca62b 100644 --- a/packages/mcp/src/shared/follow-up-command-text.ts +++ b/packages/mcp/src/shared/follow-up-command-text.ts @@ -1,4 +1,5 @@ import { formatRepositoryTarget } from "./repository-target.js"; +import { shellQuote } from "./shell-quote.js"; import type { UnifiedSearchHitPayload } from "./unified-search-response.js"; interface CodeReadCommandInput { @@ -16,13 +17,16 @@ interface CodeReadCommandInput { export function buildSearchHitFollowUpCommand( hit: UnifiedSearchHitPayload, + syntax: "mcp" | "cli" = "mcp", ): string { const loc = hit.locator; if (loc.pageId) { - return buildDocsReadCommand(loc.pageId, loc.startLine, loc.endLine); + return syntax === "cli" + ? buildCliDocsReadCommand(loc.pageId, loc.startLine, loc.endLine) + : buildDocsReadCommand(loc.pageId, loc.startLine, loc.endLine); } if (loc.filePath) { - return buildCodeReadCommand({ + const input: CodeReadCommandInput = { registry: loc.registry, packageName: loc.packageName, version: loc.version, @@ -33,7 +37,10 @@ export function buildSearchHitFollowUpCommand( startLine: loc.startLine, endLine: loc.endLine, preferPackageTarget: isPackageTarget(hit), - }); + }; + return syntax === "cli" + ? buildCliCodeReadCommand(input) + : buildCodeReadCommand(input); } if (hit.type === "repository_code" || hit.type === "repository_symbol") { return "follow-up unavailable: missing filePath"; @@ -42,6 +49,37 @@ export function buildSearchHitFollowUpCommand( return ""; } +function buildCliDocsReadCommand( + pageId: string, + startLine?: number, + endLine?: number, +): string { + const parts = [`githits docs read ${shellQuote(pageId)}`]; + appendCliRange(parts, startLine, endLine); + return parts.join(" "); +} + +function buildCliCodeReadCommand(input: CodeReadCommandInput): string { + if (!input.filePath) return "follow-up unavailable: missing filePath"; + const target = buildTargetSpec(input); + if (!target) return "follow-up unavailable: missing target"; + + const parts: string[] = ["githits code read"]; + if ( + input.repoUrl && + !(input.preferPackageTarget && input.registry && input.packageName) + ) { + parts.push("--repo-url", shellQuote(input.repoUrl)); + const ref = input.gitRef ?? input.requestedRef; + if (ref) parts.push("--git-ref", shellQuote(ref)); + } else { + parts.push(shellQuote(target)); + } + parts.push(shellQuote(input.filePath)); + appendCliRange(parts, input.startLine, input.endLine); + return parts.join(" "); +} + export function buildDocsReadCommand( pageId: string, startLine?: number, @@ -97,6 +135,18 @@ function appendRange( if (typeof endLine === "number") parts.push(`end_line=${endLine}`); } +function appendCliRange( + parts: string[], + startLine: number | undefined, + endLine: number | undefined, +): void { + if (typeof startLine !== "number" && typeof endLine !== "number") return; + parts.push( + "--lines", + `${typeof startLine === "number" ? startLine : ""}-${typeof endLine === "number" ? endLine : ""}`, + ); +} + function quote(value: string): string { return JSON.stringify(value); } diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 1571b1c0..cdb623e7 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -163,9 +163,7 @@ describe("renderUnifiedSearchStatusText", () => { }), ); expect(firstLine(text)).toStartWith(status); - expect(text).toContain( - "Do not call search_status again for this session.", - ); + expect(text).toContain("Do not poll this session again."); expect(text).not.toContain("Next: search_status"); }, ); @@ -184,7 +182,7 @@ describe("renderUnifiedSearchStatusText", () => { expect(firstLine(text)).toBe( "FUTURE_SESSION_STATE - no result snapshot returned", ); - expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Do not poll this session again."); expect(text).not.toContain("Next: search_status"); }); }); diff --git a/packages/mcp/src/shared/unified-search-status-text.ts b/packages/mcp/src/shared/unified-search-status-text.ts index 1d56bb9f..7650090a 100644 --- a/packages/mcp/src/shared/unified-search-status-text.ts +++ b/packages/mcp/src/shared/unified-search-status-text.ts @@ -3,17 +3,27 @@ import type { UnifiedSearchStatusCompletedPayload, UnifiedSearchStatusIncompletePayload, } from "./unified-search-response.js"; -import { renderUnifiedSearchPresentationText } from "./unified-search-text.js"; +import { + renderUnifiedSearchPresentationText, + type UnifiedSearchTextOptions, +} from "./unified-search-text.js"; type StatusPayload = | UnifiedSearchStatusCompletedPayload | UnifiedSearchStatusIncompletePayload; -export function renderUnifiedSearchStatusText(payload: StatusPayload): string { +export function renderUnifiedSearchStatusText( + payload: StatusPayload, + options: UnifiedSearchTextOptions = {}, +): string { const presentation = projectUnifiedSearchPresentation(payload); const result = payload.result; - return renderUnifiedSearchPresentationText(presentation, { - results: result?.results ?? [], - nextOffset: result?.nextOffset, - }); + return renderUnifiedSearchPresentationText( + presentation, + { + results: result?.results ?? [], + nextOffset: result?.nextOffset, + }, + options, + ); } diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index c50a6c4a..ac166282 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -273,6 +273,71 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.match(/Next:/g)).toHaveLength(1); }); + it("keeps one layout while rendering surface-native commands", () => { + const payload = n8nActiveEmpty(); + const mcp = renderUnifiedSearchSuccess(payload); + const cli = renderUnifiedSearchSuccess(payload, { actionSyntax: "cli" }); + + expect(cli).toContain( + "Next: githits search-status fabUr1S3MEVeSgD93pMoSQ --wait 20", + ); + expect(cli).not.toContain("search_status search_ref="); + expect( + cli.replace( + "Next: githits search-status fabUr1S3MEVeSgD93pMoSQ --wait 20", + "Next: ", + ), + ).toBe( + mcp.replace( + 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', + "Next: ", + ), + ); + + const code = renderUnifiedSearchSuccess(completed([codeHit()]), { + actionSyntax: "cli", + }); + expect(code).toContain( + "githits code read 'npm:cline@v3.4.2' 'src/integrations/diff/strategies/multi-search-replace.ts' --lines 142-156", + ); + + const repositoryCode = renderUnifiedSearchSuccess( + completed([ + codeHit({ + target: "github:cline/cline#main", + locator: { + repoUrl: "https://github.com/cline/cline", + gitRef: "main", + filePath: "src/index.ts", + startLine: 10, + endLine: 20, + }, + }), + ]), + { actionSyntax: "cli" }, + ); + expect(repositoryCode).toContain( + "githits code read --repo-url 'https://github.com/cline/cline' --git-ref 'main' 'src/index.ts' --lines 10-20", + ); + + const docs = renderUnifiedSearchSuccess(completed([docsHit()]), { + actionSyntax: "cli", + }); + expect(docs).toContain("githits docs read 'aider/edit-formats'"); + + const empty = renderUnifiedSearchSuccess( + completed([], { + query: { raw: "router", filters: { kind: "function" } }, + sourceStatus: [source({ codeIndexState: "CURRENT", resultCount: 0 })], + }), + { actionSyntax: "cli" }, + ); + expect(empty).toContain("use --source symbol"); + expect(empty).toContain("use githits code grep"); + expect(empty).not.toContain('source="symbol"'); + expect(empty).not.toContain("code_grep"); + }); + it("omits a singular target for multiple active progress targets", () => { const text = renderUnifiedSearchSuccess( incomplete({ @@ -456,9 +521,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(terminalText).toContain( "Next: retry one suggested site target explicitly.", ); - expect(terminalText).toContain( - "Do not call search_status again for this session.", - ); + expect(terminalText).toContain("Do not poll this session again."); expect(terminalText).not.toContain("Next: search_status"); }); @@ -678,9 +741,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toStartWith(status); - expect(text).toContain( - "Do not call search_status again for this session.", - ); + expect(text).toContain("Do not poll this session again."); expect(text).not.toContain("Next: search_status"); }, ); @@ -699,7 +760,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toBe( "FUTURE_SESSION_STATE - no result snapshot returned", ); - expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Do not poll this session again."); expect(text).not.toContain("Next: search_status"); expect(text).not.toContain("indexing"); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 335370f3..04b01174 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -17,6 +17,7 @@ */ import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; +import { colors, dim, highlight, highlightRanges } from "./colors.js"; import { buildSearchHitFollowUpCommand } from "./follow-up-command-text.js"; import { isHealthySearchLifecycleState } from "./search-lifecycle.js"; import { @@ -55,13 +56,22 @@ type SearchSuccessPayload = /** Render a successful unified-search payload as line-oriented text. */ export function renderUnifiedSearchSuccess( payload: SearchSuccessPayload, + options: UnifiedSearchTextOptions = {}, ): string { return renderUnifiedSearchPresentationText( projectUnifiedSearchPresentation(payload), payload, + options, ); } +export interface UnifiedSearchTextOptions { + /** Apply terminal emphasis; false keeps the MCP/CLI wording plain. */ + useColors?: boolean; + /** Surface-native syntax for the continuation action. */ + actionSyntax?: "mcp" | "cli"; +} + export interface UnifiedSearchTextResult { results: UnifiedSearchHitPayload[]; nextOffset?: number; @@ -71,15 +81,17 @@ export interface UnifiedSearchTextResult { export function renderUnifiedSearchPresentationText( presentation: UnifiedSearchPresentation, result: UnifiedSearchTextResult, + options: UnifiedSearchTextOptions = {}, ): string { + const settings = normalizeTextOptions(options); const lines: string[] = [ - formatPresentationOutcome(presentation, result.results), + formatPresentationOutcome(presentation, result.results, settings), ]; - appendPresentationContext(lines, presentation); + appendPresentationContext(lines, presentation, settings); if (result.results.length > 0) { lines.push(""); - appendUnifiedSearchHits(lines, result.results); + appendUnifiedSearchHits(lines, result.results, settings); } const hasPostResultBlock = @@ -104,15 +116,30 @@ export function renderUnifiedSearchPresentationText( lines.push(nextOffsetHint); } - appendPresentationAlternatives(lines, presentation); - appendPresentationSiteSuggestions(lines, presentation); - appendPresentationAction(lines, presentation); + appendPresentationAlternatives(lines, presentation, settings); + appendPresentationSiteSuggestions(lines, presentation, settings); + appendPresentationAction(lines, presentation, settings); return lines.join("\n"); } +interface NormalizedTextOptions { + useColors: boolean; + actionSyntax: "mcp" | "cli"; +} + +function normalizeTextOptions( + options: UnifiedSearchTextOptions, +): NormalizedTextOptions { + return { + useColors: options.useColors ?? false, + actionSyntax: options.actionSyntax ?? "mcp", + }; +} + function formatPresentationOutcome( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], + options: NormalizedTextOptions, ): string { const target = presentationTarget(presentation, results); const targetSuffix = target ? ` ${target}` : ""; @@ -122,28 +149,79 @@ function formatPresentationOutcome( if (presentation.lifecycle.kind === "active") { const label = activeLifecycleLabel(presentation.lifecycle); if (presentation.availability.kind === "no_snapshot") { - return `${label}${targetSuffix} - no result snapshot returned yet`; + return styleOutcome( + `${label}${targetSuffix} - no result snapshot returned yet`, + presentation, + options.useColors, + ); } if (presentation.availability.kind === "empty") { - return `${label}${targetSuffix} - no results returned yet`; + return styleOutcome( + `${label}${targetSuffix} - no results returned yet`, + presentation, + options.useColors, + ); } const resultKind = presentation.availability.kind === "partial" ? "partial" : "interim"; - return `${label} continues - ${countLabel.replace("result", `${resultKind} result`)} returned`; + return styleOutcome( + `${label} continues - ${countLabel.replace("result", `${resultKind} result`)} returned`, + presentation, + options.useColors, + ); } if (presentation.lifecycle.kind === "completed") { - return count > 0 - ? `${countLabel}${target ? ` from ${target}` : ""}` - : `No results returned${target ? ` from ${target}` : ""}`; + return styleOutcome( + count > 0 + ? `${countLabel}${target ? ` from ${target}` : ""}` + : `No results returned${target ? ` from ${target}` : ""}`, + presentation, + options.useColors, + ); } const status = presentation.lifecycle.status ?? "UNKNOWN"; - if (count > 0) return `${status} - ${countLabel} returned`; + if (count > 0) + return styleOutcome( + `${status} - ${countLabel} returned`, + presentation, + options.useColors, + ); if (presentation.availability.kind === "no_snapshot") { - return `${status} - no result snapshot returned`; + return styleOutcome( + `${status} - no result snapshot returned`, + presentation, + options.useColors, + ); + } + return styleOutcome( + `${status} - no results returned`, + presentation, + options.useColors, + ); +} + +function styleOutcome( + value: string, + presentation: UnifiedSearchPresentation, + useColors: boolean, +): string { + if (!useColors) return value; + if ( + presentation.lifecycle.kind === "active" || + (presentation.lifecycle.kind === "terminal" && + presentation.lifecycle.status !== "FAILED") + ) { + return `${colors.bold}${colors.yellow}${value}${colors.reset}`; + } + if ( + presentation.lifecycle.kind === "terminal" && + presentation.lifecycle.status === "FAILED" + ) { + return `${colors.bold}${colors.red}${value}${colors.reset}`; } - return `${status} - no results returned`; + return `${colors.bold}${value}${colors.reset}`; } function activeLifecycleLabel( @@ -190,6 +268,7 @@ function presentationTarget( function appendPresentationContext( lines: string[], presentation: UnifiedSearchPresentation, + options: NormalizedTextOptions, ): void { if (presentation.progress) { lines.push( @@ -202,8 +281,8 @@ function appendPresentationContext( presentation.sources, presentation.trustLimits, ); - appendPresentationTrust(lines, presentation.trustLimits); - appendPresentationWarnings(lines, presentation.warnings); + appendPresentationTrust(lines, presentation.trustLimits, options); + appendPresentationWarnings(lines, presentation.warnings, options); } function appendPresentationTargetDivergence( @@ -343,24 +422,33 @@ function formatCoverageLimit( function appendPresentationTrust( lines: string[], trustLimits: UnifiedSearchTrustLimit[], + options: NormalizedTextOptions, ): void { const trust = trustLimits.filter((limit) => limit.kind !== "source"); for (const limit of trust) { switch (limit.kind) { case "stale": lines.push( - `Evidence: ${limit.requestedTarget ? `requested ${limit.requestedTarget}; ` : ""}served older snapshot ${limit.servedTarget ?? limit.target ?? "unknown target"}${limit.freshTarget ? ` while ${limit.freshTarget} indexes` : ""}.`, + dim( + `Evidence: ${limit.requestedTarget ? `requested ${limit.requestedTarget}; ` : ""}served older snapshot ${limit.servedTarget ?? limit.target ?? "unknown target"}${limit.freshTarget ? ` while ${limit.freshTarget} indexes` : ""}.`, + options.useColors, + ), ); break; case "provisional": - lines.push("Evidence: provisional snapshot; indexing continues."); + lines.push( + dim( + "Evidence: provisional snapshot; indexing continues.", + options.useColors, + ), + ); break; case "coverage": break; case "constraint": case "mutable_evidence": if (limit.kind === "mutable_evidence") - lines.push("Evidence may change."); + lines.push(dim("Evidence may change.", options.useColors)); break; } } @@ -369,16 +457,27 @@ function appendPresentationTrust( function appendPresentationWarnings( lines: string[], warnings: UnifiedSearchWarning[], + options: NormalizedTextOptions, ): void { if (warnings.length === 0) return; - lines.push("Warnings:"); + lines.push( + options.useColors + ? `${colors.bold}${colors.yellow}Warnings:${colors.reset}` + : "Warnings:", + ); for (const warning of warnings) { - if (warning.kind === "query") lines.push(` - ${warning.message}`); + if (warning.kind === "query") + lines.push( + options.useColors + ? ` - ${colors.yellow}${warning.message}${colors.reset}` + : ` - ${warning.message}`, + ); else { const label = warning.kind.replaceAll("_", " "); const source = warning.source ? ` (${warning.source})` : ""; + const value = ` - ${capitalize(label)}${source}: ${warning.values.join(", ")}`; lines.push( - ` - ${capitalize(label)}${source}: ${warning.values.join(", ")}`, + options.useColors ? `${colors.yellow}${value}${colors.reset}` : value, ); } } @@ -387,6 +486,7 @@ function appendPresentationWarnings( function appendPresentationAlternatives( lines: string[], presentation: UnifiedSearchPresentation, + options: NormalizedTextOptions, ): void { for (const alternative of presentation.alternatives) { const categories: string[] = []; @@ -409,7 +509,8 @@ function appendPresentationAlternatives( lines.push( ...wrapText( `Indexed alternatives${presentation.alternatives.length > 1 && alternative.target ? ` for ${alternative.target}` : ""}: ${categories.join("; ")}`, - ), + SUMMARY_WRAP_WIDTH, + ).map((line) => dim(line, options.useColors)), ); } } @@ -418,6 +519,7 @@ function appendPresentationAlternatives( function appendPresentationSiteSuggestions( lines: string[], presentation: UnifiedSearchPresentation, + options: NormalizedTextOptions, ): void { const seen = new Set(); const rendered = presentation.siteSuggestions.flatMap((facts) => { @@ -433,11 +535,12 @@ function appendPresentationSiteSuggestions( lines.push( ...wrapText( `Suggested site targets${targetSuffix}: ${suggestions.join(", ")}`, - ), + SUMMARY_WRAP_WIDTH, + ).map((line) => dim(line, options.useColors)), ); } if (presentation.siteSuggestions.some((facts) => facts.truncated)) { - lines.push("Additional site targets were omitted."); + lines.push(dim("Additional site targets were omitted.", options.useColors)); } } @@ -448,6 +551,7 @@ function formatRemaining(count: number): string { function appendPresentationAction( lines: string[], presentation: UnifiedSearchPresentation, + options: NormalizedTextOptions, ): void { const action = presentation.action; if (action.kind === "none") { @@ -466,9 +570,11 @@ function appendPresentationAction( ? "Do not repeat immediately." : "Do not repeat search.", ); - lines.push( - `Next: search_status search_ref=${JSON.stringify(action.searchRef)} wait_timeout_ms=${DEFAULT_WAIT_TIMEOUT_MS}`, - ); + const next = + options.actionSyntax === "cli" + ? `Next: githits search-status ${action.searchRef} --wait ${DEFAULT_WAIT_TIMEOUT_MS / 1000}` + : `Next: search_status search_ref=${JSON.stringify(action.searchRef)} wait_timeout_ms=${DEFAULT_WAIT_TIMEOUT_MS}`; + lines.push(highlight(next, options.useColors)); return; } if (action.kind === "new_search") { @@ -476,7 +582,7 @@ function appendPresentationAction( presentation.lifecycle.kind === "terminal" || presentation.lifecycle.kind === "unknown" ) { - lines.push("Do not call search_status again for this session."); + lines.push("Do not poll this session again."); } else if (presentation.availability.kind === "empty") { lines.push("Do not repeat immediately."); } @@ -496,7 +602,7 @@ function appendPresentationAction( lines.push( presentation.lifecycle.kind === "terminal" || presentation.lifecycle.kind === "unknown" - ? "Do not call search_status again for this session." + ? "Do not poll this session again." : "Do not repeat immediately.", ); lines.push("Next: retry one suggested site target explicitly."); @@ -508,7 +614,11 @@ function appendPresentationAction( ? "Do not repeat immediately." : "Do not repeat this search unchanged.", ); - lines.push(`Next: ${action.rewrites.map(formatRewrite).join("; ")}.`); + lines.push( + `Next: ${action.rewrites + .map((rewrite) => formatRewrite(rewrite, options.actionSyntax)) + .join("; ")}.`, + ); } } @@ -527,6 +637,7 @@ function formatRewrite( rewrite: NonNullable< Extract >["rewrites"][number], + syntax: "mcp" | "cli", ): string { switch (rewrite) { case "shorter_or_broader": @@ -534,9 +645,9 @@ function formatRewrite( case "remove_filters": return "remove restrictive filters"; case "symbol": - return 'use source="symbol"'; + return syntax === "cli" ? "use --source symbol" : 'use source="symbol"'; case "code_grep": - return "use code_grep"; + return syntax === "cli" ? "use githits code grep" : "use code_grep"; case "site_shorter_or_broader": return "shorten or broaden site query"; } @@ -572,10 +683,11 @@ export function renderUnifiedSearchError( function appendUnifiedSearchHits( lines: string[], hits: UnifiedSearchHitPayload[], + options: NormalizedTextOptions, ): void { hits.forEach((hit, idx) => { if (idx > 0) lines.push(""); - appendHit(lines, idx + 1, hit); + appendHit(lines, idx + 1, hit, options); }); } @@ -583,27 +695,60 @@ function appendHit( lines: string[], index: number, hit: UnifiedSearchHitPayload, + options: NormalizedTextOptions, ): void { - const headerParts: string[] = [formatHitPrimary(hit), shortType(hit.type)]; + const headerParts: string[] = [ + highlight(formatHitPrimary(hit), options.useColors), + shortType(hit.type), + ]; lines.push(`[${index}] ${headerParts.join(" ")}`); - const locator = buildLocatorLine(hit); + const locator = buildLocatorLine(hit, options.actionSyntax); if (locator) lines.push(` ${locator}`); // Title is suppressed when it's literally the locator we just // printed; the response builder already drops `qualifiedPath` when // it equals `title`, so we don't double-check that here. if (hit.title && hit.title !== hit.locator.filePath) { - lines.push(` ${hit.title}`); + lines.push( + ` ${highlightRanges(hit.title, hit.highlights?.title, options.useColors)}`, + ); } if (hit.summary) { - for (const wrapped of wrapText(hit.summary, SUMMARY_WRAP_WIDTH)) { + for (const wrapped of wrapHighlightedText( + hit.summary, + hit.highlights?.summary, + SUMMARY_WRAP_WIDTH, + options.useColors, + )) { lines.push(` ${wrapped}`); } } } +function wrapHighlightedText( + text: string, + ranges: ReadonlyArray | undefined, + width: number, + useColors: boolean, +): string[] { + const wrapped = wrapText(text, width); + if (!useColors || !ranges || ranges.length === 0) return wrapped; + const highlighted: string[] = []; + let cursor = 0; + for (const line of wrapped) { + const start = text.indexOf(line, cursor); + const offset = start >= 0 ? start : cursor; + const localRanges = ranges.map( + ([from, to]) => [from - offset, to - offset] as const, + ); + highlighted.push(highlightRanges(line, localRanges, true)); + cursor = offset + line.length; + } + return highlighted; +} + function formatHitPrimary(hit: UnifiedSearchHitPayload): string { const loc = hit.locator; if (hit.type === "documentation_page" && loc.pageId) { @@ -656,9 +801,12 @@ function shortType(type: string): string { } } -function buildLocatorLine(hit: UnifiedSearchHitPayload): string { +function buildLocatorLine( + hit: UnifiedSearchHitPayload, + actionSyntax: "mcp" | "cli", +): string { const loc = hit.locator; - const followUp = buildSearchHitFollowUpCommand(hit); + const followUp = buildSearchHitFollowUpCommand(hit, actionSyntax); if (followUp) { const tail: string[] = []; if (loc.qualifiedPath) tail.push(loc.qualifiedPath); diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index b1c2933f..333ad7c9 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -350,7 +350,7 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("TIMEOUT - no result snapshot returned"); expect(text).not.toContain("search_status |"); - expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); }); @@ -367,7 +367,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-failed" }, {}); const text = result.content[0]?.text ?? ""; expect(text).toContain("FAILED - no result snapshot returned"); - expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); }); @@ -412,7 +412,7 @@ describe("searchStatusTool", () => { const text = textResult.content[0]?.text ?? ""; expect(text).toContain("DEFERRED - 1 result returned"); expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -473,7 +473,7 @@ describe("searchStatusTool", () => { const text = textResult.content[0]?.text ?? ""; expect(text).toContain("FUTURE_SESSION_STATE - 1 result returned"); expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not call search_status again for this session."); + expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 4dc932f9..b71a78ee 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, mock, spyOn } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import type { UnifiedSearchIncomplete, UnifiedSearchOutcome, @@ -38,6 +38,10 @@ const CLI_TERMS_ERROR_PAYLOAD = { }, }; +afterEach(() => { + mock.restore(); +}); + function createDocumentationSearchResult(): UnifiedSearchResult { return { query: "router", @@ -115,6 +119,13 @@ function createDivergentIndexingSearchResult(): UnifiedSearchResult { }; } +function stripAnsi(value: string): string { + return value.replace(ANSI_SGR_PATTERN, ""); +} + +const ESC = String.fromCharCode(0x1b); +const ANSI_SGR_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); + describe("searchAction", () => { const mcpUrl = "https://mcp.githits.com"; @@ -505,25 +516,27 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Documentation sources:"); + expect(output.split("\n")[0]).toBe( + "No results returned from npm:express@5.1.0", + ); expect(output).toContain( - "repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567", + "Searched: repository docs (https://github.com/expressjs/express @", ); expect(output).toContain( - "site expressjs.com/en/guide - available, but not searched for this response; the available snapshot is older; published snapshot is partial: 120 pages included", + "Available but not searched: expressjs.com/en/guide docs (120 pages; partial)", ); - expect(output).not.toContain("hits on this page"); + expect(output).not.toContain("Documentation sources:"); expect(output).not.toContain("Documentation corpora"); expect(output).not.toContain("indexing is still in progress"); expect(output).toContain("Do not repeat immediately."); expect(output).not.toContain("Try a shorter or broader query"); expect(output).not.toContain("Run again with a larger --wait"); - expect(output.split(DOCUMENTATION_EVIDENCE_NOTICE)).toHaveLength(2); + expect(output).toContain("Evidence may change."); expect(output).toContain("githits search-status search-ref-docs"); consoleSpy.mockRestore(); }); - it("scopes empty CLI claims to searched evidence when a source was not searched", async () => { + it("scopes empty CLI claims with searched and unsearched evidence", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); const result = createDocumentationSearchResult(); result.evidenceNotice = undefined; @@ -544,11 +557,12 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("No hits in the searched evidence on this page."); - expect(output).not.toContain("No results."); - expect(output).toContain( - "Try a shorter or broader query, or search another source.", + expect(output.split("\n")[0]).toBe( + "No results returned from npm:express@5.1.0", ); + expect(output).toContain("Searched: repository docs"); + expect(output).toContain("Available but not searched:"); + expect(output).toContain("Do not repeat immediately."); consoleSpy.mockRestore(); }); @@ -587,11 +601,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("No hits in the searched evidence on this page."); - expect(output).toContain( - "Run again with a larger --wait while indexing finishes.", - ); - expect(output).not.toContain("Try a shorter or broader query"); + expect(output).toContain("Waiting: code"); + expect(output).toContain("Searched: repository docs"); + expect(output).toContain("Next: rerun search later."); consoleSpy.mockRestore(); }); @@ -638,7 +650,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Try a shorter or broader query."); + expect(output).toContain("Next: shorten or broaden site query."); expect(output).not.toContain("search another source"); consoleSpy.mockRestore(); }); @@ -691,9 +703,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "1 result | 1 docs page\nSearched: repo https://github.com/expressjs/express @ 0123456789abcdef0123456789abcdef01234567; site expressjs.com/en/guide", - ); + expect(output.split("\n")[0]).toBe("1 result from npm:express@5.1.0"); + expect(output).toContain("Searched: repository docs"); + expect(output).toContain("site docs"); + expect(output).toContain("[1] express/routing npm:express docs"); + expect(output).toContain("githits docs read 'express/routing'"); expect(output).not.toContain("Documentation sources"); expect(output).not.toContain("hits on this page"); expect(output).not.toContain("124 pages"); @@ -915,10 +929,12 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("1 repo code hit"); + expect(output.split("\n")[0]).toBe("1 result from npm:express@4.18.2"); + expect(output).toContain("[1] npm:express@4.18.2 code"); expect(output).toContain( - "npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", + "githits code read 'npm:express@4.18.2' 'lib/router/index.js' --lines 42-57", ); + expect(output).toContain("router middleware"); consoleSpy.mockRestore(); }); @@ -949,10 +965,75 @@ describe("searchAction", () => { }), ); - expect(String(consoleSpy.mock.calls[0]?.[0])).toContain( - "Indexing/search still in progress", + const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output.split("\n")[0]).toBe( + "Indexing - no result snapshot returned yet", + ); + expect(output).toContain("Ready: 0/1 targets"); + expect(output).toContain( + "Next: githits search-status search-ref-123 --wait 20", + ); + consoleSpy.mockRestore(); + }); + + it("uses the shared n8n hierarchy with CLI-native status actions", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + const source = defaultUnifiedSearchOutcome.result.sourceStatus[0]; + if (!source) throw new Error("expected source fixture"); + const outcome: UnifiedSearchIncomplete = { + ...createIncompleteOutcome("INDEXING", "n8n-search-ref"), + result: { + ...defaultUnifiedSearchOutcome.result, + query: "human review approval node output", + results: [], + page: { + ...defaultUnifiedSearchOutcome.result.page, + returned: 0, + }, + sourceStatus: [ + { + ...source, + targetLabel: "npm:n8n@2.36.7", + resultCount: 0, + indexingStatus: "INDEXING", + codeIndexState: "PENDING", + }, + ], + }, + }; + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + search: mock(() => Promise.resolve(outcome)), + searchStatus: mock(() => Promise.resolve(outcome)), + }), + }); + + await searchAction( + "human review approval node output", + { in: ["npm:n8n"] }, + deps, + ); + const initial = String(consoleSpy.mock.calls[0]?.[0]); + expect(initial.split("\n")[0]).toBe( + "Indexing npm:n8n@2.36.7 - no results returned yet", ); - expect(String(consoleSpy.mock.calls[0]?.[0])).toContain("search-ref-123"); + expect(initial).toContain("Ready: 0/1 targets"); + expect(initial).toContain( + "Next: githits search-status n8n-search-ref --wait 20", + ); + expect(initial.match(/^Indexing\b/gm)).toHaveLength(1); + expect(initial.match(/^Ready:/gm)).toHaveLength(1); + expect(initial.match(/^Next:/gm)).toHaveLength(1); + expect(initial.match(/n8n-search-ref/g)).toHaveLength(1); + expect(initial.split("\n").length).toBeLessThanOrEqual(7); + expect(initial).not.toContain("search_status search_ref="); + + await searchStatusAction("n8n-search-ref", {}, deps); + const status = String(consoleSpy.mock.calls[1]?.[0]); + expect(status).toBe(initial); consoleSpy.mockRestore(); }); @@ -972,11 +1053,14 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Documentation sources:"); + expect(output.split("\n")[0]).toBe( + "Indexing npm:express@5.1.0 - no results returned yet", + ); + expect(output).toContain("Searched: repository docs"); expect(output).toContain( - "site expressjs.com/en/guide - available, but not searched for this response", + "Available but not searched: expressjs.com/en/guide docs (120 pages; partial)", ); - expect(output.split(DOCUMENTATION_EVIDENCE_NOTICE)).toHaveLength(2); + expect(output.match(/Evidence may change\./g)).toHaveLength(1); expect(output).toContain("githits search-status search-ref-docs"); consoleSpy.mockRestore(); }); @@ -1000,12 +1084,10 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Search deferred."); - expect(output).toContain( - "Background lifecycle work continues outside this search session.", - ); - expect(output).toContain("Stored evidence remains usable."); - expect(output).toContain("1 result"); + expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); expect(output).not.toContain("still indexing"); @@ -1036,12 +1118,12 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Search status is not recognized: FUTURE_SESSION_STATE.", + expect(output.split("\n")[0]).toBe( + "FUTURE_SESSION_STATE - 1 result returned", ); - expect(output).toContain("This client does not recognize that status."); - expect(output).toContain("Stored evidence remains usable."); - expect(output).toContain("1 result"); + expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); expect(output).not.toContain("still indexing"); @@ -1088,16 +1170,15 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Indexing/search still in progress"); - expect(output).toContain( - "Warning: Source 'docs' for site:example.com: incompatible filters [language]", + expect(output.split("\n")[0]).toBe( + "Indexing site:example.com - no results returned yet", ); + expect(output).toContain("Waiting: site docs"); expect(output).toContain( - "site:example.com: Suggested site targets: site:docs.example.com", - ); - expect(output).toContain( - "site:example.com: Additional site targets were omitted.", + "Incompatible filter (site:example.com): language", ); + expect(output).toContain("Suggested site targets: site:docs.example.com"); + expect(output).toContain("Additional site targets were omitted."); consoleSpy.mockRestore(); }); @@ -1143,9 +1224,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Warning: Source 'docs' for npm:express@4.18.2: ignored filters [fileIntent]", - ); + expect(output).toContain("Ignored filter (npm:express@4.18.2): fileIntent"); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); }); @@ -1193,7 +1272,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "Warning: Source 'docs' for npm:express@4.18.2: incompatible query features [name]; ignored query features [kind]", + "Ignored query feature (npm:express@4.18.2): kind", + ); + expect(output).toContain( + "Incompatible query feature (npm:express@4.18.2): name", ); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); @@ -1237,7 +1319,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "Warning: requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", + "Evidence: requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", ); consoleSpy.mockRestore(); }); @@ -1354,10 +1436,13 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("1 result"); - expect(output).toContain("provisional (still indexing)"); - expect(output).toContain("served=github:expressjs/express#main@abc1237"); - expect(output).toContain("indexingRef=idx_123"); - expect(output).toContain("search-ref-123"); + expect(output).toContain( + "Evidence: provisional snapshot; indexing continues.", + ); + expect(output).toContain("Evidence may change."); + expect(output).toContain( + "Next: githits search-status search-ref-123 --wait 20", + ); consoleSpy.mockRestore(); }); @@ -1413,10 +1498,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "Using recent indexed snapshot while branch resolution is deferred", + "Evidence: served older snapshot github:expressjs/express#refs/heads/master.", ); - expect(output).toContain("served=github:expressjs/express#master@abc1237"); - expect(output).not.toContain("Search still in progress"); + expect(output).toContain("Indexed alternatives: refs master"); + expect(output).not.toContain("Next: githits search-status"); consoleSpy.mockRestore(); }); @@ -1484,10 +1569,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("\u001b[1m\u001b[33mmiddleware\u001b[0m"); expect(output).toContain( - "\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m", + "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", + "\u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m code", ); } finally { consoleSpy.mockRestore(); @@ -1503,7 +1588,7 @@ describe("searchAction", () => { } }); - it("prefers longer overlapping query terms for location highlights", async () => { + it("keeps color output text-identical after ANSI is stripped", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); const originalIsTTY = process.stdout.isTTY; const noColor = process.env.NO_COLOR; @@ -1514,15 +1599,25 @@ describe("searchAction", () => { configurable: true, }); - await searchAction("route router", { in: ["npm:express"] }, createDeps()); - - const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m", + await searchAction( + "router middleware", + { in: ["npm:express"] }, + createDeps(), ); - expect(output).not.toContain( - "\u001b[1m\u001b[33mroute\u001b[0m\u001b[1m\u001b[36mr/index.js", + + const colorOutput = String(consoleSpy.mock.calls[0]?.[0]); + + process.env.NO_COLOR = "1"; + await searchAction( + "router middleware", + { in: ["npm:express"] }, + createDeps(), ); + + const plainOutput = String(consoleSpy.mock.calls[1]?.[0]); + expect(colorOutput).toContain("\u001b["); + expect(plainOutput).not.toContain("\u001b["); + expect(stripAnsi(colorOutput)).toBe(plainOutput); } finally { consoleSpy.mockRestore(); Object.defineProperty(process.stdout, "isTTY", { @@ -1639,10 +1734,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "docs-123 [docs page] npm:express - Using Express middleware - hexdocs.pm/express/getting-started.html", - ); - expect(output).toContain("docs-123"); + expect(output).toContain("[1] docs-123 npm:express docs"); + expect(output).toContain("githits docs read 'docs-123'"); + expect(output).toContain("Using Express middleware"); expect(output).not.toContain("source:"); expect(output).not.toContain("npm:express@4.18.2 [docs page]"); expect(output).not.toContain("read:"); @@ -1688,9 +1782,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "docs-routing [docs page] docs.example - Routing - docs.example/routing", - ); + expect(output).toContain("[1] docs-routing docs.example docs"); + expect(output).toContain("githits docs read 'docs-routing'"); + expect(output).toContain("Routing"); consoleSpy.mockRestore(); }); }); @@ -1808,8 +1902,14 @@ describe("searchStatusAction", () => { }), ); - expect(String(consoleSpy.mock.calls[0]?.[0])).toContain("search-ref-123"); - expect(String(consoleSpy.mock.calls[0]?.[0])).toContain("searching"); + const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output.split("\n")[0]).toBe( + "Searching - no result snapshot returned yet", + ); + expect(output).toContain("Ready: 1/1 targets"); + expect(output).toContain( + "Next: githits search-status search-ref-123 --wait 20", + ); consoleSpy.mockRestore(); }); @@ -1840,8 +1940,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output.split("\n")[0]).toBe( + "Indexing site:example.com/old - no result snapshot returned yet", + ); expect(output).toContain( - "Warning: requested site:example.com; served older snapshot site:example.com/old while site:example.com indexes.", + "Target: requested site:example.com; fresh site:example.com; served site:example.com/old", ); consoleSpy.mockRestore(); }); @@ -1892,12 +1995,13 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Warning: requested site:example.com; served older snapshot site:example.com/old while site:example.com indexes.", + expect(output.split("\n")[0]).toBe( + "Indexing site:example.com/old - no results returned yet", ); expect(output).toContain( - "site:example.com: Suggested site targets: site:docs.example.com", + "Target: requested site:example.com; fresh site:example.com; served site:example.com/old", ); + expect(output).toContain("Suggested site targets: site:docs.example.com"); consoleSpy.mockRestore(); }); @@ -1985,13 +2089,13 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("targets:"); + expect(output.split("\n")[0]).toBe( + "Indexing github:expressjs/express#master - no result snapshot returned yet", + ); expect(output).toContain( - "requested=github:expressjs/express#refs/heads/master", + "Target: requested github:expressjs/express#refs/heads/master; fresh github:expressjs/express#master; served github:expressjs/express#master", ); - expect(output).toContain("fresh=github:expressjs/express#master"); - expect(output).toContain("Requested ref is being indexed"); - expect(output).toContain("queryable now: refs=master"); + expect(output).toContain("Indexed alternatives: refs master"); consoleSpy.mockRestore(); }); @@ -2015,9 +2119,9 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Search timed out."); - expect(output).toContain("This search session is terminal."); - expect(output).toContain("Start a new search."); + expect(output.split("\n")[0]).toBe("TIMEOUT - no result snapshot returned"); + expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("longer wait"); expect(output).not.toContain("Search still in progress."); consoleSpy.mockRestore(); @@ -2072,12 +2176,9 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Search deferred."); - expect(output).toContain( - "Background lifecycle work continues outside this search session.", - ); - expect(output).toContain("Stored evidence remains usable."); - expect(output).toContain("1 result"); + expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain("Do not poll this session again."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); @@ -2109,12 +2210,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Search status is not recognized: FUTURE_SESSION_STATE.", + expect(output.split("\n")[0]).toBe( + "FUTURE_SESSION_STATE - 1 result returned", ); - expect(output).toContain("This client does not recognize that status."); - expect(output).toContain("Stored evidence remains usable."); - expect(output).toContain("1 result"); + expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain("Do not poll this session again."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); @@ -2140,10 +2240,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Search deferred."); - expect(output).toContain( - "Background lifecycle work continues outside this search session.", + expect(output.split("\n")[0]).toBe( + "DEFERRED - no result snapshot returned", ); + expect(output).toContain("Do not poll this session again."); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); expect(output).not.toContain("githits search-status"); @@ -2170,7 +2270,7 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Search failed."); + expect(output.split("\n")[0]).toBe("FAILED - no result snapshot returned"); expect(output).not.toContain("Search still in progress."); consoleSpy.mockRestore(); }); @@ -2241,12 +2341,18 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Documentation sources:"); + expect(output.split("\n")[0]).toBe( + "No results returned from npm:express@5.1.0", + ); + expect(output).toContain("Searched: repository docs"); expect(output).toContain( - "site expressjs.com/en/guide - available, but not searched for this response", + "Available but not searched: expressjs.com/en/guide docs (120 pages; partial)", ); - expect(output.split(DOCUMENTATION_EVIDENCE_NOTICE)).toHaveLength(2); - expect(output).not.toContain("githits search-status search-ref-docs"); + expect(output.match(/Evidence may change\./g)).toHaveLength(1); + expect( + output.match(/githits search-status search-ref-docs --wait 20/g), + ).toHaveLength(1); + expect(output).not.toContain("Search completed"); expect(output).not.toContain("re-run with the searchRef"); consoleSpy.mockRestore(); }); @@ -2276,9 +2382,12 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("No hits in the searched evidence on this page."); + expect(output.split("\n")[0]).toBe( + "No results returned from npm:express@5.1.0", + ); + expect(output).toContain("Searched: repository docs"); expect(output).toContain( - "Try a shorter or broader query, or search another source.", + "Unavailable: site docs (https://expressjs.com/en/guide)", ); consoleSpy.mockRestore(); }); @@ -2326,7 +2435,7 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Try a shorter or broader query."); + expect(output).toContain("Next: shorten or broaden site query."); expect(output).not.toContain("search another source"); consoleSpy.mockRestore(); }); @@ -2390,7 +2499,10 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m", + "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", + ); + expect(output).toContain( + "\u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m code", ); } finally { consoleSpy.mockRestore(); diff --git a/src/commands/search.ts b/src/commands/search.ts index 4346715a..1f7ac2c9 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -3,36 +3,26 @@ import type { UnifiedSearchSource, } from "@githits/core-internal"; import { - appendDocumentationSources, - appendEmptySearchGuidance, buildUnifiedSearchErrorPayload, buildUnifiedSearchParams, buildUnifiedSearchStatusPayload, buildUnifiedSearchSuccessPayload, DEFAULT_WAIT_TIMEOUT_MS, - dim, - formatProgressTarget, - formatSuggestedSiteTargetGuidance, - highlight, - highlightMatch, - highlightRanges, InvalidArgumentError, - isActiveUnifiedSearchSessionStatus, knownSymbolCategoryList, knownSymbolKindList, - type LeanTargetResolution, MAX_WAIT_TIMEOUT_MS, type MappedError, parseUnifiedSearchTargetSpec, + renderUnifiedSearchStatusText, + renderUnifiedSearchSuccess, requireAuth, shouldUseColors, toFileIntent, toSymbolCategory, toSymbolKind, type UnifiedSearchErrorPayload, - type UnifiedSearchSourceStatusPayload, - type UnifiedSearchStatusIncompletePayload, - type UnifiedSearchStatusResultPayload, + type UnifiedSearchTextOptions, } from "@githits/mcp/internal"; import { type Command, Option } from "commander"; import { recordCliErrorClassification } from "../shared/cli-error-diagnostics.js"; @@ -124,11 +114,7 @@ export async function searchAction( return; } - console.log( - formatUnifiedSearchTerminal(payload, { - includeCompletedSearchRefFollowUp: true, - }), - ); + console.log(renderUnifiedSearchSuccess(payload, cliSearchTextOptions())); } catch (error) { handleSearchError(error, options.json ?? false); } @@ -159,21 +145,7 @@ export async function searchStatusAction( return; } - if (!payload.completed) { - if (payload.result) { - console.log( - formatSearchStatusPartialTerminal({ - ...payload, - result: payload.result, - }), - ); - } else { - console.log(formatSearchStatusTerminal(payload, payload.warnings)); - } - return; - } - - console.log(formatSearchStatusCompletedTerminal(payload)); + console.log(renderUnifiedSearchStatusText(payload, cliSearchTextOptions())); } catch (error) { handleSearchError(error, options.json ?? false, "status"); } @@ -446,789 +418,9 @@ function formatSearchErrorTerminal( return formatted; } -function formatUnifiedSearchTerminal( - payload: { - completed: boolean; - hasMore: boolean; - nextOffset?: number; - results: Array<{ - type: string; - target: string; - title?: string; - summary?: string; - highlights?: { - title?: Array; - summary?: Array; - }; - locator: { - registry?: string; - packageName?: string; - version?: string; - repoUrl?: string; - gitRef?: string; - requestedRef?: string; - pageId?: string; - sourceKind?: string; - sourceUrl?: string; - filePath?: string; - startLine?: number; - endLine?: number; - }; - }>; - searchRef?: string; - progress?: SearchProgressForTerminal; - query: { raw?: string; warnings?: string[] }; - warnings?: string[]; - sourceStatus?: SourceStatusEntry[]; - evidenceNotice?: string; - }, - options: { includeCompletedSearchRefFollowUp?: boolean } = {}, -): string { - const lines: string[] = []; - const useColors = shouldUseColors(); - - const warnings = payload.warnings ?? payload.query.warnings; - if (warnings && warnings.length > 0) { - for (const warning of warnings) { - lines.push(`Warning: ${warning}`); - } - lines.push(""); - } - - const sourceStatusNotes = formatSourceStatusNotes( - payload.sourceStatus, - warnings, - payload.completed, - payload.progress?.status, - ); - const documentationSourceNotes = formatDocumentationSourcesTerminal( - payload.sourceStatus, - payload.results, - ); - const evidenceNotes = payload.evidenceNotice - ? [`Evidence notice: ${payload.evidenceNotice}`] - : []; - if ( - options.includeCompletedSearchRefFollowUp && - payload.completed && - payload.evidenceNotice && - payload.searchRef - ) { - evidenceNotes.push( - `next: githits search-status ${payload.searchRef} --wait ${DEFAULT_WAIT_TIMEOUT_MS / 1000}`, - ); - } - const provenanceNotes = [...sourceStatusNotes, ...evidenceNotes]; - const emptyResultNotes = [ - ...sourceStatusNotes, - ...documentationSourceNotes, - ...evidenceNotes, - ]; - - if (!payload.completed) { - const statusText = formatSearchStatusTerminal({ - completed: false, - searchRef: payload.searchRef ?? "", - progress: payload.progress, - }); - lines.push(statusText); - if (payload.results.length === 0) { - if (emptyResultNotes.length > 0) { - lines.push(""); - lines.push(...emptyResultNotes); - } - return lines.join("\n").trimEnd(); - } - lines.push(""); - lines.push("Partial results:"); - } - - if (payload.results.length === 0) { - appendEmptySearchGuidance(lines, { - sourceStatus: payload.sourceStatus, - evidenceNotice: payload.evidenceNotice, - guidanceStyle: "cli", - fallbackHeadline: "No results.", - }); - if (emptyResultNotes.length > 0) { - lines.push(""); - lines.push(...emptyResultNotes); - } - return lines.join("\n").trimEnd(); - } - - const { display, duplicatesFolded } = dedupeSearchResultsForDisplay( - payload.results, - ); - - const baseCount = `${display.length} result${display.length === 1 ? "" : "s"}`; - const countSuffix = [ - payload.hasMore ? " (more available)" : "", - duplicatesFolded > 0 ? ` (+${duplicatesFolded} near-duplicate folded)` : "", - ].join(""); - const typeSummary = formatUnifiedSearchTypeSummary(display); - lines.push( - `${highlight(baseCount, useColors)}${dim(countSuffix, useColors)}${typeSummary ? dim(` | ${typeSummary}`, useColors) : ""}`, - ); - if (documentationSourceNotes.length > 0) { - lines.push(...documentationSourceNotes); - } - lines.push(""); - - for (const entry of display) { - const location = formatUnifiedSearchLocation(entry.locator); - const header = formatUnifiedSearchHeader( - entry, - useColors, - location, - payload.query.raw, - ); - lines.push(header); - const metadata = formatUnifiedSearchMetadata(entry, useColors); - if (metadata.length > 0) { - lines.push(...metadata); - } - if (entry.summary) { - lines.push( - ...formatUnifiedSearchSummary( - entry.summary, - entry.highlights?.summary, - useColors, - ), - ); - } - lines.push(""); - } - - if (payload.nextOffset !== undefined) { - lines.push(dim(`Next offset: ${payload.nextOffset}`, useColors)); - } - - if (provenanceNotes.length > 0) { - lines.push(""); - lines.push(...provenanceNotes); - } - - return lines.join("\n").trimEnd(); -} - -function formatSearchStatusTerminal( - payload: { - completed: false; - searchRef: string; - progress?: SearchProgressForTerminal; - }, - warnings?: string[], -): string { - const status = payload.progress?.status; - const lines: string[] = []; - if (warnings && warnings.length > 0) { - for (const warning of warnings) { - lines.push(`Warning: ${warning}`); - } - lines.push(""); - } - lines.push(formatSearchStatusHeadline(status)); - lines.push(`searchRef: ${payload.searchRef}`); - if (payload.progress) { - if (payload.progress.status) { - lines.push(`status: ${payload.progress.status.toLowerCase()}`); - } - if ( - typeof payload.progress.targetsReady === "number" && - typeof payload.progress.targetsTotal === "number" - ) { - lines.push( - `targets ready: ${payload.progress.targetsReady}/${payload.progress.targetsTotal}`, - ); - } - if (payload.progress.targets && payload.progress.targets.length > 0) { - lines.push("targets:"); - for (const target of payload.progress.targets) { - lines.push(` - ${formatProgressTarget(target)}`); - } - } - } - if (status === "TIMEOUT") { - lines.push("This search session is terminal. Start a new search."); - return lines.join("\n"); - } - if (status === "DEFERRED") { - lines.push( - "Background lifecycle work continues outside this search session.", - ); - lines.push( - "Use any disclosed evidence now. Start a new search later for a fresher snapshot.", - ); - return lines.join("\n"); - } - if (status === "FAILED") { - lines.push( - "Search failed. Start a new search or inspect backend errors if the failure persists.", - ); - return lines.join("\n"); - } - if (status !== undefined && !isActiveUnifiedSearchSessionStatus(status)) { - lines.push("This client does not recognize that status."); - lines.push("Use any disclosed evidence now. Start a new search later."); - return lines.join("\n"); - } - lines.push( - `next: githits search-status ${payload.searchRef} --wait ${DEFAULT_WAIT_TIMEOUT_MS / 1000}`, - ); - return lines.join("\n"); -} - -function formatSearchStatusHeadline(status: string | undefined): string { - switch (status) { - case "PENDING": - case "INDEXING": - case "SEARCHING": - return "Indexing/search still in progress."; - case "DEFERRED": - return "Search deferred."; - case "TIMEOUT": - return "Search timed out."; - case "FAILED": - return "Search failed."; - default: - return status - ? `Search status is not recognized: ${status}.` - : "Search still in progress."; - } -} - -function formatSearchStatusCompletedTerminal(payload: { - completed: true; - searchRef?: string; - result: UnifiedSearchStatusResultPayload; -}): string { - return formatUnifiedSearchTerminal({ - completed: true, - hasMore: payload.result.hasMore, - nextOffset: payload.result.nextOffset, - results: payload.result.results, - searchRef: payload.searchRef, - progress: undefined, - query: { - raw: payload.result.query?.raw, - warnings: payload.result.warnings, - }, - warnings: payload.result.warnings, - sourceStatus: payload.result.sourceStatus, - evidenceNotice: payload.result.evidenceNotice, - }); -} - -function formatSearchStatusPartialTerminal( - payload: UnifiedSearchStatusIncompletePayload & { - result: UnifiedSearchStatusResultPayload; - }, -): string { - const warnings = Array.from( - new Set([...(payload.warnings ?? []), ...(payload.result.warnings ?? [])]), - ); - return formatUnifiedSearchTerminal({ - completed: false, - hasMore: payload.result.hasMore, - nextOffset: payload.result.nextOffset, - results: payload.result.results, - searchRef: payload.searchRef, - progress: payload.progress, - query: { - raw: payload.result.query?.raw, - warnings: payload.result.warnings, - }, - warnings: warnings.length > 0 ? warnings : undefined, - sourceStatus: payload.result.sourceStatus, - evidenceNotice: payload.result.evidenceNotice, - }); -} - -type SourceStatusEntry = UnifiedSearchSourceStatusPayload; - -interface SearchProgressForTerminal { - targetsReady?: number; - targetsTotal?: number; - status?: string; - targets?: Array<{ - requested?: string; - resolvedRequested?: string; - served?: string; - freshness?: string; - indexingRef?: string; - requestedRefKind?: string; - targetResolution?: LeanTargetResolution; - availableVersions?: Array<{ version?: string; ref: string }>; - availableRefs?: Array<{ version?: string; ref: string }>; - suggestedRefs?: Array<{ version?: string; ref: string }>; - }>; -} - -function formatSourceStatusNotes( - sourceStatus: SourceStatusEntry[] | undefined, - warnings: string[] | undefined, - completed: boolean, - sessionStatus: string | undefined, -): string[] { - const useColors = shouldUseColors(); - if (!sourceStatus) { - return []; - } - - const nonActiveIncompleteSession = - !completed && - sessionStatus !== undefined && - !isActiveUnifiedSearchSessionStatus(sessionStatus); - const lines: string[] = []; - for (const entry of sourceStatus) { - for (const guidance of formatSuggestedSiteTargetGuidance(entry)) { - lines.push(dim(`${entry.targetLabel}: ${guidance}`, useColors)); - } - const warningPrefix = `Source '${entry.source.toLowerCase()}' for ${entry.targetLabel}:`; - if (warnings?.some((warning) => warning.startsWith(warningPrefix))) { - continue; - } - const label = `${entry.source.toLowerCase()} on ${entry.targetLabel}`; - if (entry.ignoredFilters && entry.ignoredFilters.length > 0) { - lines.push( - dim( - `Note: ${label} ignored filters: ${entry.ignoredFilters.join(", ")}`, - useColors, - ), - ); - } - if (entry.incompatibleFilters && entry.incompatibleFilters.length > 0) { - lines.push( - dim( - `Note: ${label} incompatible filters: ${entry.incompatibleFilters.join(", ")}`, - useColors, - ), - ); - } - if (entry.ignoredQueryFeatures && entry.ignoredQueryFeatures.length > 0) { - lines.push( - dim( - `Note: ${label} ignored query features: ${entry.ignoredQueryFeatures.join(", ")}`, - useColors, - ), - ); - } - if ( - entry.incompatibleQueryFeatures && - entry.incompatibleQueryFeatures.length > 0 - ) { - lines.push( - dim( - `Note: ${label} incompatible query features: ${entry.incompatibleQueryFeatures.join(", ")}`, - useColors, - ), - ); - } - if (entry.indexingStatus === "INDEXING" && !nonActiveIncompleteSession) { - lines.push( - dim( - completed - ? `Note: ${label} still indexing.` - : `Note: ${label} still indexing — re-run with the searchRef for full results.`, - useColors, - ), - ); - } - if (entry.note) { - lines.push(dim(`Note: ${label}: ${entry.note}`, useColors)); - } - } - - return lines; -} - -function formatDocumentationSourcesTerminal( - sourceStatus: SourceStatusEntry[] | undefined, - results: Array<{ target: string }>, -): string[] { - const lines: string[] = []; - appendDocumentationSources(lines, sourceStatus, results); - const useColors = shouldUseColors(); - return lines.map((line) => { - if (line === "") return line; - const terminalLine = - line.startsWith("searched") || line.startsWith("documentation sources") - ? `${line[0]?.toUpperCase()}${line.slice(1)}` - : line; - return dim(terminalLine, useColors); - }); -} - -function dedupeSearchResultsForDisplay< - T extends { - type: string; - target: string; - title?: string; - summary?: string; - locator: { pageId?: string; filePath?: string }; - }, ->(results: T[]): { display: T[]; duplicatesFolded: number } { - const seen = new Set(); - const display: T[] = []; - let duplicatesFolded = 0; - for (const entry of results) { - const key = [ - entry.type, - entry.target, - entry.title ?? "", - (entry.summary ?? "").slice(0, 120), - ].join(""); - const dedupeKey = `${key}\u0001${entry.locator.pageId ?? entry.locator.filePath ?? ""}`; - if (seen.has(dedupeKey)) { - duplicatesFolded += 1; - continue; - } - seen.add(dedupeKey); - display.push(entry); - } - return { display, duplicatesFolded }; -} - -function formatUnifiedSearchTypeSummary( - results: Array<{ type: string }>, -): string { - const counts = new Map(); - for (const result of results) { - counts.set(result.type, (counts.get(result.type) ?? 0) + 1); - } - - return Array.from(counts.entries()) - .map(([type, count]) => formatUnifiedSearchCountLabel(type, count)) - .join(", "); -} - -function formatUnifiedSearchResultLabel(type: string): string { - switch (type) { - case "documentation_page": - return "docs page"; - case "repository_doc": - return "repo doc"; - case "repository_code": - return "repo code"; - case "repository_symbol": - return "repo symbol"; - default: - return type.replaceAll("_", " "); - } -} - -function formatUnifiedSearchCountLabel(type: string, count: number): string { - switch (type) { - case "documentation_page": - return `${count} docs page${count === 1 ? "" : "s"}`; - case "repository_doc": - return `${count} repo doc${count === 1 ? "" : "s"}`; - case "repository_code": - return `${count} repo code hit${count === 1 ? "" : "s"}`; - case "repository_symbol": - return `${count} repo symbol${count === 1 ? "" : "s"}`; - default: - return `${count} ${formatUnifiedSearchResultLabel(type)}`; - } -} - -function formatUnifiedSearchSummary( - summary: string, - ranges: Array | undefined, - useColors: boolean, -): string[] { - const lines = summary.split(/\r\n|\n/); - - // Preserve backend snippets verbatim. We only style spans the backend already - // computed instead of trimming or rewriting the snippet client-side. - let offset = 0; - return lines.map((line) => { - const lineStart = offset; - const lineEnd = lineStart + line.length; - const lineRanges = (ranges ?? []) - .map( - ([start, end]) => - [Math.max(start, lineStart), Math.min(end, lineEnd)] as const, - ) - .filter(([start, end]) => end > start) - .map(([start, end]) => [start - lineStart, end - lineStart] as const); - const separatorLength = summary.startsWith("\r\n", lineEnd) ? 2 : 1; - offset = lineEnd + separatorLength; - return ` ${highlightRanges(line, lineRanges, useColors)}`; - }); -} - -function formatUnifiedSearchLocation(locator: { - filePath?: string; - startLine?: number; - endLine?: number; - sourceUrl?: string; -}): string | undefined { - if (!locator.filePath) { - return locator.sourceUrl; - } - - if (!locator.startLine) { - return locator.filePath; - } - - return `${locator.filePath}:${locator.startLine}${locator.endLine && locator.endLine !== locator.startLine ? `-${locator.endLine}` : ""}`; -} - -function formatUnifiedSearchHeader( - entry: { - target: string; - type: string; - highlights?: { title?: Array }; - locator: { - filePath?: string; - startLine?: number; - endLine?: number; - pageId?: string; - registry?: string; - packageName?: string; - sourceKind?: string; - sourceUrl?: string; - requestedRef?: string; - version?: string; - }; - title?: string; - }, - useColors: boolean, - location: string | undefined, - rawQuery: string | undefined, -): string { - if (entry.type === "documentation_page") { - return formatDocumentationPageHeader(entry, useColors); - } - - const primary = formatUnifiedSearchPrimary( - entry.type, - entry.target, - location, - rawQuery, - useColors, - ); - const badge = `[${formatUnifiedSearchResultLabel(entry.type)}]`; - const title = entry.title - ? highlightRanges(entry.title, entry.highlights?.title, useColors) - : undefined; - return `${primary} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`; -} - -function formatDocumentationPageHeader( - entry: { - target: string; - highlights?: { title?: Array }; - locator: { - pageId?: string; - registry?: string; - packageName?: string; - sourceUrl?: string; - }; - title?: string; - }, - useColors: boolean, -): string { - const pageId = entry.locator.pageId ?? "unknown"; - const title = entry.title - ? highlightRanges(entry.title, entry.highlights?.title, useColors) - : "Untitled documentation page"; - const source = entry.locator.sourceUrl - ? ` - ${formatDisplayUrl(entry.locator.sourceUrl)}` - : ""; - const target = formatDocsPageTarget(entry.locator, entry.target); - return `${highlight(pageId, useColors)} ${dim("[docs page]", useColors)}${target ? ` ${dim(target, useColors)}` : ""} - ${title}${dim(source, useColors)}`; -} - -function formatDisplayUrl(value: string): string { - return value.replace(/^https?:\/\//, ""); -} - -function formatDocsPageTarget( - locator: { - registry?: string; - packageName?: string; - version?: string; - }, - fallbackTarget?: string, -): string { - return locator.registry && locator.packageName - ? `${locator.registry}:${locator.packageName}` - : stripVersionFromTarget(fallbackTarget); -} - -function stripVersionFromTarget(value: string | undefined): string { - if (!value) return ""; - const atIndex = value.lastIndexOf("@"); - return atIndex > 0 ? value.slice(0, atIndex) : value; -} - -function formatUnifiedSearchPrimary( - type: string, - target: string, - location: string | undefined, - rawQuery: string | undefined, - useColors: boolean, -): string { - const formattedTarget = highlight(target, useColors); - if (type === "documentation_page" || !location) { - return formattedTarget; - } - - return `${formattedTarget} ${formatLocationWithQueryHighlights( - location, - rawQuery, - useColors, - )}`; -} - -function formatLocationWithQueryHighlights( - location: string, - rawQuery: string | undefined, - useColors: boolean, -): string { - const ranges = buildQueryTermRanges(location, rawQuery); - if (ranges.length === 0) return highlight(location, useColors); - if (!useColors) return location; - - let result = ""; - let cursor = 0; - for (const [start, end] of ranges) { - if (cursor < start) - result += highlight(location.slice(cursor, start), true); - result += highlightMatch(location.slice(start, end), true); - cursor = end; - } - if (cursor < location.length) - result += highlight(location.slice(cursor), true); - return result; -} - -function buildQueryTermRanges( - text: string, - rawQuery: string | undefined, -): Array { - const terms = extractQueryHighlightTerms(rawQuery); - if (terms.length === 0) return []; - - const lowerText = text.toLowerCase(); - const ranges: Array = []; - const orderedTerms = [...terms].sort( - (left, right) => right.length - left.length, - ); - for (const term of orderedTerms) { - const lowerTerm = term.toLowerCase(); - let cursor = 0; - while (cursor < lowerText.length) { - const start = lowerText.indexOf(lowerTerm, cursor); - if (start === -1) break; - const end = start + lowerTerm.length; - if (!ranges.some((range) => rangesOverlap(range, [start, end]))) { - ranges.push([start, end]); - } - cursor = end; - } - } - - return mergeRanges(ranges); -} - -function extractQueryHighlightTerms(rawQuery: string | undefined): string[] { - if (!rawQuery) return []; - - const booleanOperators = new Set(["AND", "OR", "NOT"]); - const terms = new Set(); - const quotedRanges: Array = []; - // Preserve quoted phrases as a single best-effort location term so a phrase - // query does not degrade into scattered word highlights in paths. - for (const match of rawQuery.matchAll(/"([^"]+)"/g)) { - const phrase = match[1]; - if (phrase) { - addQueryHighlightTerm(phrase, terms, booleanOperators, { - stripQualifier: false, - }); - } - if (typeof match.index === "number") { - quotedRanges.push([match.index, match.index + match[0].length]); - } - } - - for (const match of rawQuery.matchAll(/[A-Za-z0-9_./@:-]+/g)) { - const index = match.index ?? 0; - if (quotedRanges.some(([start, end]) => index >= start && index < end)) { - continue; - } - addQueryHighlightTerm(match[0], terms, booleanOperators); - } - - return Array.from(terms); -} - -function addQueryHighlightTerm( - candidate: string, - terms: Set, - booleanOperators: Set, - options: { stripQualifier: boolean } = { stripQualifier: true }, -): void { - const normalised = - options.stripQualifier && /^[A-Za-z]+:.+/.test(candidate) - ? candidate.split(":").slice(1).join(":") - : candidate; - const term = normalised.replace(/^[-+]+/, "").replace(/[-+]+$/, ""); - if (term.length < 2) return; - if (booleanOperators.has(term.toUpperCase())) return; - terms.add(term); -} - -function rangesOverlap( - left: readonly [number, number], - right: readonly [number, number], -): boolean { - return left[0] < right[1] && right[0] < left[1]; -} - -function mergeRanges( - ranges: Array, -): Array { - const sorted = ranges - .filter(([start, end]) => end > start) - .sort((left, right) => left[0] - right[0] || left[1] - right[1]); - const merged: Array = []; - for (const current of sorted) { - const previous = merged[merged.length - 1]; - if (!previous || current[0] > previous[1]) { - merged.push(current); - continue; - } - merged[merged.length - 1] = [ - previous[0], - Math.max(previous[1], current[1]), - ]; - } - return merged; -} - -function formatUnifiedSearchMetadata( - entry: { - type: string; - locator: { - pageId?: string; - sourceKind?: string; - sourceUrl?: string; - }; - }, - _useColors: boolean, -): string[] { - if (entry.type !== "documentation_page" && entry.type !== "repository_doc") { - return []; - } - - const lines: string[] = []; - if (entry.type === "documentation_page") { - return lines; - } - - return lines; +function cliSearchTextOptions(): UnifiedSearchTextOptions { + return { + useColors: shouldUseColors(), + actionSyntax: "cli", + }; } From 16df3d532d6cfd3fac98800f4112543c6cfddcd3 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:19:51 +0300 Subject: [PATCH 25/46] docs: record shared search formatter contract Document CLI as the inspectable MCP text harness, update the single-formatter ownership boundary, and mark the CLI search-output phase implemented in the existing patch fragment. --- changes/search-output-hierarchy.changed.md | 2 +- docs/implementation/cli-commands.md | 8 +- docs/implementation/mcp-cli-parity.md | 29 +-- docs/implementation/tools.md | 8 +- docs/plans/search-output-ux.md | 197 +++++++++------------ 5 files changed, 116 insertions(+), 128 deletions(-) diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md index 402a4856..e5c3d9c1 100644 --- a/changes/search-output-hierarchy.changed.md +++ b/changes/search-output-hierarchy.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Clarify unified search output** - Add exact partial-result truth to JSON and make MCP search and search-status text outcome-first with concise lifecycle, readiness, provenance, and continuation guidance. +- **Clarify unified search output** - Add exact partial-result truth to JSON and route CLI and MCP search/search-status through one outcome-first formatter with concise lifecycle, readiness, provenance, ANSI hierarchy, and surface-native continuation guidance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index d782b7b4..f280692a 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -232,15 +232,15 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. -**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth; it does not change the current CLI human terminal rendering. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. Callers follow an explicit rendered `search-status` action rather than repeating `search`; ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. Callers follow the one rendered `search-status` action rather than repeating `search`; ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** Plain output preserves backend ranking order. It starts with a lightweight per-type count summary, then shows one result per block. The CLI human renderer remains the current terminal format; the outcome-first migration applies to MCP text-v1. The header line is optimized for scanning and copy-paste follow-up: `target path:range [type] - title`. For file-backed hits, that header can be turned directly into a `githits code read` call because `code read` accepts `path:start-end` suffixes. Summaries are rendered verbatim from the backend response. Labels are: `docs page` (hosted package docs), `repo doc` (documentation-like block from a repository file), `repo code` (code block from a repository file), and `repo symbol` (explicit symbol hit from the repository index). `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The first line says what was returned and whether work continues, followed by one readiness/trust block, result blocks, bounded alternatives or provenance, and at most one next action. Both surfaces therefore have the same wording, order, and wrapping. CLI enables ANSI emphasis when supported and substitutes directly executable CLI actions (`githits search-status`, `githits code read`, `githits docs read`, and `githits code grep`) for MCP tool-call syntax. Removing ANSI from CLI output leaves the same text contract apart from those supplied commands. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. -**Highlighting.** The CLI applies the backend's structured `highlights` spans on titles and summaries, plus structural emphasis on headers and badges. It does **not** attempt client-side substring highlighting for terms the backend did not flag, since the compiled query is not a faithful match spec. +**Highlighting.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, and optional evidence or alternatives are dim. Color never carries meaning and does not change wording or wrapping. -**Trust signals.** The JSON `sourceStatus` block is included when a source reports an actionable condition or a DOCS row discloses physical `contributors`. It retains each source's state, freshness, current-page `resultCount`, and exact repository or docpack metadata. Human output is intentionally smaller. Directly below the result count, current, searched, fully published sources collapse to `Searched: site ...; repo ... @ `. A single target needs no package label; multi-target searches retain labels only to disambiguate each source set. The summary does not repeat freshness, page coverage, or hit counts already conveyed by the result count. If a source is stale, provisional, partial, capped, not ready, unavailable, or missing published coverage details, a **Documentation sources** block appears before the hits and explains that exception in plain language, including whether each source was searched. A searched provisional contributor explicitly says that the provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline says only that the searched evidence returned no hits. Without a pending-evidence notice, CLI output then suggests one applicable next step: a larger `--wait` for active indexing, a shorter or broader query for standalone sites, or a query/source change for package and repository targets. Partial and capped coverage are usable published evidence; they never imply indexing progress or retryability. Coverage details remain lossless in JSON, including the stable `siteKey`, canonical `siteUrl`, explicit null frontier state, artifact overflow, reason, estimate, and note. Human output derives the compact site host/path only from that contributor URL, so zero-hit and non-empty pages identify the same searched site without inspecting result URLs. If `siteUrl` is absent, the generic `site documentation` label is retained; current CLI text adds numbers only when displayed identities collide. +**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups its structured facts into `Waiting`, `Searched`, `Available but not searched`, and `Unavailable`; exact requested/fresh/served divergence appears once only when identities differ. Stale, provisional, capped, or mutable evidence is qualified once, while raw reason codes, indexing references, promoted duplicate warnings, and opaque evidence prose remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. Contributor-bearing rows omit redundant pair-level `resultCount`, pair-level `coverage`, and healthy resolution metadata from the compact JSON projection. Other source-status signals remain unchanged: ignored / incompatible filters and query features, terminal indexing notes, promoted freshness warnings, and ordered standalone-site recovery targets. Site suggestions come from `suggestedSiteTargets`; the exact `suggestedSiteTargetsTruncated` Boolean is retained whenever suggestions are present. They are advisory labels to retry explicitly, not aliases, and the client never selects or retries one automatically. diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 310734b5..d7201b0c 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -235,8 +235,9 @@ test suite anchors the doc. ### Search output parity -MCP `search` and `search_status` default `text-v1` evolve in place. -The MCP text contract is outcome-first: one outcome line, one concise readiness +CLI human `search` / `search-status` and MCP `search` / `search_status` default +`text-v1` use one shared formatter that evolves in place. The text contract is +outcome-first: one outcome line, one concise readiness and trust summary, result blocks, bounded alternatives or provenance, and one action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct; active no-snapshot output says that no result snapshot was returned, while active empty @@ -248,23 +249,31 @@ guidance; they are never selected automatically. Parser/query and structured constraint facts appear once below the outcome, while promoted lifecycle warning prose and opaque evidence text stay out of default MCP text. -The three MCP anti-repeat directives are part of this text behavior: +The three anti-repeat directives are part of this text behavior: `Do not repeat search.` for active polling, `Do not repeat this search unchanged.` for an ordinary completed empty result, and `Do not repeat immediately.` for evidence-limited or status-continuation actions. A rendered `searchRef` appears -only in the exact `Next: search_status search_ref=... wait_timeout_ms=...` -action; terminal and unknown responses instead give an explicit no-poll -instruction. - -The CLI human renderer remains unchanged. Its `--json` output and MCP -`format: "json"` output remain the structured parity boundary: every +only in the exact continuation action. MCP renders +`Next: search_status search_ref=... wait_timeout_ms=...`; CLI renders +`Next: githits search-status ... --wait ...`. Terminal and unknown responses +instead give an explicit transport-neutral no-poll instruction. + +CLI supplies ANSI enablement and CLI-native follow-up commands to the shared +formatter; MCP supplies no color and MCP-native tool-call syntax. Hierarchy, +wording, wrapping, and fact selection are otherwise identical. Search-result +follow-ups likewise render as `githits code read` / `githits docs read` in CLI +and `code_read` / `docs_read` in MCP. ANSI-stripped CLI output is structurally +identical to no-color output. + +CLI `--json` output and MCP `format: "json"` output remain the structured parity +boundary: every result-bearing initial payload and stored `search_status.result` carries the backend's exact `partialResults: boolean`, including both `false` and `true`; payloads with no result snapshot omit that field. Full `warnings[]`, source diagnostics, evidence notices, reason codes, references, and alternative lists remain available in JSON even when MCP text classifies or bounds them for readability. The shared JSON parity tests compare these envelopes deeply; only -surface-native text and follow-up syntax differ. +surface-native follow-up syntax and ANSI differ. ### `PARITY-ERROR-ENVELOPE` diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index d7c031da..9893895a 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -282,9 +282,9 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, and `pkg_upgrade_review` text mode reuse the shared no-color terminal formatters but inject MCP-native hints. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass verbose=true`, `body_lines=`, or `format="json"` when text omitted lines. Package tools keep JSON errors in all formats because agents can reliably branch on `{error, code, retryable, details?}`. -**Unified search outcome-first anatomy** (`search` and `search_status` text-v1). The first nonblank line is one outcome: `Preparing`, `Indexing`, or `Searching` for active `PENDING`, `INDEXING`, or `SEARCHING`; a completed result/empty count; or the exact `DEFERRED`, `TIMEOUT`, `FAILED`, or unknown status. A no-snapshot active response says `no result snapshot returned yet`; an active empty snapshot says `no results returned yet`; active hits are labelled `interim` when `partialResults` is false and `partial` when it is true. The remainder is ordered as one readiness/trust summary, query or structured constraint warnings, result blocks, bounded alternatives/provenance and site suggestions, then one action. Progress-only responses show only derivable target readiness and alternatives; they never synthesize source or contributor facts. +**Unified search outcome-first anatomy** (CLI human search/search-status and MCP `search` / `search_status` text-v1). One shared formatter owns both surfaces. The first nonblank line is one outcome: `Preparing`, `Indexing`, or `Searching` for active `PENDING`, `INDEXING`, or `SEARCHING`; a completed result/empty count; or the exact `DEFERRED`, `TIMEOUT`, `FAILED`, or unknown status. A no-snapshot active response says `no result snapshot returned yet`; an active empty snapshot says `no results returned yet`; active hits are labelled `interim` when `partialResults` is false and `partial` when it is true. The remainder is ordered as one readiness/trust summary, query or structured constraint warnings, result blocks, bounded alternatives/provenance and site suggestions, then one action. Progress-only responses show only derivable target readiness and alternatives; they never synthesize source or contributor facts. -Active output keeps `Do not repeat search.` immediately before one exact action such as `Next: search_status search_ref="..." wait_timeout_ms=20000`. Ordinary completed empty output keeps `Do not repeat this search unchanged.`; evidence-limited output uses `Do not repeat immediately.`; terminal and unknown statuses prohibit polling the stopped or unrecognized reference. Suggested site targets retain backend order and an omitted-candidates signal, but are advisory labels rather than automatic retries. Unified search text prints `searchRef` only inside its exact `Next:` action. +Active output keeps `Do not repeat search.` immediately before one exact action. MCP renders `Next: search_status search_ref="..." wait_timeout_ms=20000`; CLI renders `Next: githits search-status ... --wait 20`. Ordinary completed empty output keeps `Do not repeat this search unchanged.`; evidence-limited output uses `Do not repeat immediately.`; terminal and unknown statuses prohibit polling the stopped or unrecognized reference. Suggested site targets retain backend order and an omitted-candidates signal, but are advisory labels rather than automatic retries. Unified search text prints `searchRef` only inside its exact `Next:` action. **Hit anatomy within unified search text-v1:** @@ -300,11 +300,11 @@ Active output keeps `Do not repeat search.` immediately before one exact action More hits available. Pass offset=N for the next page or limit=N to widen. ``` -`` compacts to `code` / `symbol` / `docs` / `repo-docs`. `` is a ready-to-call follow-up when possible: `code_read target="npm:pkg@version" path="..." start_line=N end_line=M` for code/symbol hits and `docs_read page_id="..."` for documentation hits. If a code/symbol hit lacks a file path, text mode prints `follow-up unavailable: missing filePath` rather than fabricating a path. +`` compacts to `code` / `symbol` / `docs` / `repo-docs`. `` is a ready-to-call follow-up when possible. MCP uses `code_read target="npm:pkg@version" path="..." start_line=N end_line=M` or `docs_read page_id="..."`; CLI uses the equivalent `githits code read ... --lines N-M` or `githits docs read ...`. If a code/symbol hit lacks a file path, text mode prints `follow-up unavailable: missing filePath` rather than fabricating a path. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. -Completed empty search uses the model's applicable action: generic query pivots are suppressed for evidence-limited or unsearched sources, indexing/provisional evidence prefers waiting or an indexed alternative, standalone site searches expose only a shorter/broader site query, and filter removal or symbol/code-grep pivots appear only when applicable. A completed result with both an evidence notice and `searchRef` emits one status continuation after the generic `Evidence may change.` trust statement. Terminal `DEFERRED`, `FAILED`, and `TIMEOUT` preserve disclosed evidence but prohibit further status calls; unknown statuses preserve the raw value and use the same conservative no-polling boundary. Promoted lifecycle/freshness warning prose and opaque evidence text remain in JSON but are not repeated in default MCP text; parser/query and structured constraint facts appear once below the outcome. +Completed empty search uses the model's applicable action: generic query pivots are suppressed for evidence-limited or unsearched sources, indexing/provisional evidence prefers waiting or an indexed alternative, standalone site searches expose only a shorter/broader site query, and filter removal or symbol/code-grep pivots appear only when applicable. Surface-native pivots name `source="symbol"` / `code_grep` in MCP and `--source symbol` / `githits code grep` in CLI. A completed result with both an evidence notice and `searchRef` emits one status continuation after the generic `Evidence may change.` trust statement. Terminal `DEFERRED`, `FAILED`, and `TIMEOUT` preserve disclosed evidence but prohibit further status calls; unknown statuses preserve the raw value and use the same conservative no-polling boundary. Promoted lifecycle/freshness warning prose and opaque evidence text remain in JSON but are not repeated in default text; parser/query and structured constraint facts appear once below the outcome. **Listing anatomy** (`code_files` text-v1): diff --git a/docs/plans/search-output-ux.md b/docs/plans/search-output-ux.md index 17ffeda2..62d848bc 100644 --- a/docs/plans/search-output-ux.md +++ b/docs/plans/search-output-ux.md @@ -3,9 +3,9 @@ ## Status - Overall: **IN PROGRESS** -- Phase 1a: **COMPLETE** (implemented, verified, and clean-reviewed; awaiting - draft PR/merge) -- Phase 1b: **PENDING ON PHASE 1A** +- Phase 1a: **COMPLETE** +- Phase 1b: **COMPLETE** (implemented in the same draft PR after the formatter + ownership correction; final integrated verification/review pending) - Phase 2: **PENDING** ## Problem and expected outcome @@ -61,16 +61,17 @@ When this work is complete: explicitly decided that it is not a compatibility boundary for this redesign. `text-v1` will be improved in place; no `text-v2` or legacy renderer is needed. 8. Phase 1a is implemented: the additive `partialResults` JSON field, one shared - presentation projection, outcome-first MCP `text-v1` rendering, tool/parity - assertions, and MCP smoke invariants are complete. The model's source-entry + presentation projection, outcome-first text rendering, tool/parity assertions, + and MCP smoke invariants are complete. The model's source-entry boundary now uses required `searchTarget` for the searched package context; the overloaded `contextTarget` is gone. Requested/fresh/served divergence is retained only in progress and trust facts. -9. The final repository evidence is recorded below. Phase 1a did not change CLI - human/color rendering; the reported screenshot/color problem remains Phase 1b - by design. +9. The original phase boundary was corrected after the user clarified that CLI is + the inspectable fidelity harness for MCP text and agents use both surfaces. + Phase 1b now routes CLI search/search-status through the same formatter, adds + ANSI and CLI-command inputs, and deletes the duplicated private CLI formatter. -### Final integrated Phase 1a evidence +### Final integrated Phase 1 evidence - `bun test`: 3,364 tests passed, 0 failed, 10,825 expects across 184 files. - `bun run typecheck`: clean; format and lint checked 437 files clean. @@ -83,11 +84,11 @@ When this work is complete: and Codex; usefulness was helped/high confidence. The discovered symbol-label bug was fixed. - The final focused shared/status/tool cohort passed 123 tests with 0 failures. -- Production/shared-smoke delta across the five counted source files is 1,456 - additions and 449 deletions. The original 1,450-line Phase 1a plan ceiling - was intentionally exceeded by six lines with explicit user authorization for - the root-cause boundary correction, remaining below the repository's 1.5k - implementation-code caution threshold. +- Production/shared-smoke delta across the seven counted source files is 1,683 + additions and 1,276 deletions (net +407). The single-formatter correction crossed + the addition-only caution threshold but deleted 818 lines from the CLI command and + removed the duplicated formatter instead of adding another layer. The user + explicitly authorized the root-cause correction even if it grew this PR. - `origin/main` at `739ec4e` was merged cleanly with no conflicts. Overlapping permanent documentation auto-merged, and integrated full-test, build, package, and source-smoke verification passed. @@ -136,8 +137,10 @@ When this work is complete: ### Ownership The shared search presentation layer owns the meaning and priority of response -facts. The response builder continues to own lossless structured projection. The -CLI and MCP renderers own syntax, color, and surface-native actions only. +facts. The response builder continues to own lossless structured projection. One +shared text formatter owns hierarchy, wording, wrapping, hit anatomy, and semantic +color roles. CLI and MCP callers supply only ANSI enablement and surface-native +action syntax. ```text Core UnifiedSearchOutcome @@ -147,14 +150,17 @@ shared JSON payload builder ----> CLI --json / MCP format=json | v shared search presentation model + | + v +shared search text formatter | | v v -CLI terminal renderer MCP text-v1 renderer +CLI: ANSI + CLI actions MCP: no ANSI + MCP actions ``` -This corrects the current ownership friction: CLI and MCP both need to know whether -evidence is absent, interim, partial, final, stale, or provisional, but neither -renderer should rediscover that from warning strings. +This corrects both ownership problems: neither surface rediscovers semantic state +from warning strings, and wording/layout cannot drift between duplicated renderers. +CLI output remains a directly inspectable proxy for MCP token and output quality. ### Presentation model @@ -177,7 +183,7 @@ query/filter/source problems not already represented by the lifecycle and trust dimensions. The model contains display facts, not finished sentences or ANSI codes. It retains -the exact target/source identities and continuation reference needed by renderers, +the exact target/source identities and continuation reference needed by the formatter, but omits internal-only `freshnessReason`, `requestedRefKind`, and `indexingRef` from default text unless one becomes a verified user action. Those values remain in JSON. @@ -233,12 +239,11 @@ Rules: - Keep each status/provenance line bounded and independently wrappable. Never join the complete target diagnostic record with ` | `. - Query/filter incompatibilities remain visible once, below the outcome headline. -- MCP active responses retain `Do not repeat search.` before the exact status action. +- Active responses retain `Do not repeat search.` before the exact status action. Completed empty responses retain `Do not repeat this search unchanged.`, and evidence-limited responses retain `Do not repeat immediately.` Terminal responses - retain the existing prohibition on polling a stopped reference. The CLI drops the - anti-repeat directives it currently inherits from shared empty-search guidance; - those guardrails are agent-specific and remain in MCP text only. + retain a transport-neutral prohibition on polling a stopped reference. These + guardrails remain on both surfaces because agents can invoke either one. The action dimension also preserves the existing empty-result pivot rules: @@ -250,15 +255,18 @@ The action dimension also preserves the existing empty-result pivot rules: 4. removing filters or switching to symbol search is suggested only when those pivots apply to the actual request. -### Phase 1b target: CLI shape for the reported active empty snapshot +### Implemented CLI shape for the reported active empty snapshot ```text -Indexing npm:n8n@2.36.7 — no results returned yet +Indexing npm:n8n@2.36.7 - no results returned yet Ready: 0/1 targets +Target: requested npm:n8n; fresh npm:n8n@2.36.7 Waiting: code, repository docs Available but not searched: n8n.io docs (1,480 pages; capped) -Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2; refs HEAD, master - +Evidence may change. +Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2 more; refs HEAD, +master +Do not repeat search. Next: githits search-status fabUr1S3MEVeSgD93pMoSQ --wait 20 ``` @@ -266,18 +274,15 @@ The supplied text proves this response contains an empty result snapshot with `sourceStatus` and documentation contributors: contributor identity, readiness, and page counts cannot come from progress alone. The regression fixture will encode the disclosed structured facts from the supplied output; it will not depend on reproducing -the transient production indexing state with a fresh network call. - -This remains a Phase 1b target. Phase 1a did not change CLI human/color rendering; -the exact copy may tighten during Phase 1b implementation tests, but the section -order, single-statement rules, disclosed evidence distinctions, and bounded -alternatives are acceptance constraints. +the transient production indexing state with a fresh network call. The regression +now passes through the same formatter as MCP; only the final command dialect and +ANSI option differ. A true progress-only CLI response has no `sourceStatus` or documentation contributors and therefore renders only derivable facts: ```text -Indexing npm:n8n@2.36.7 — no result snapshot returned yet +Indexing npm:n8n@2.36.7 - no result snapshot returned yet Ready: 0/1 targets Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2; refs HEAD, master @@ -289,8 +294,8 @@ It must not synthesize per-source waiting state, site identity, or page coverage ### Other response shapes ```text -Indexing continues — 4 interim results returned -Ready: 1/2 targets · results and ranking may change +Indexing continues - 4 interim results returned +Ready: 1/2 targets @@ -298,8 +303,8 @@ Next: githits search-status --wait 20 ``` ```text -Indexing continues — 4 partial results returned -Ready: 1/2 targets · some requested sources are not represented +Indexing continues - 4 partial results returned +Ready: 1/2 targets @@ -339,10 +344,9 @@ they are identifiers. ### Overall assumptions -1. Search/search-status is split into two independently reviewable increments. Phase - 1a completed shared/MCP output and structured truth; Phase 1b completes the - reported CLI production fix after Phase 1a merges. Splitting delivery does not - shrink the overall goal. +1. Phases 1a and 1b ship in one PR. The earlier merge boundary was removed after the + user clarified that CLI must be the directly inspectable fidelity harness for MCP + text and both humans and agents invoke it. 2. `text-v1` may change in place, per the user's explicit decision on 2026-08-26. 3. JSON is the correct place for full diagnostic identities, reason codes, indexing references, and unbounded alternatives. @@ -401,18 +405,17 @@ backend collections; preserve backend order and cap only display projection. ### Release boundary -Phase 1a changed MCP default text and added `partialResults` to both MCP JSON and -root CLI `--json`. Its independent fragment uses `githits: patch` and +Phase 1 changes shared MCP/CLI text and adds `partialResults` to both MCP JSON and +root CLI `--json`. Its single cohesive fragment uses `githits: patch` and `@githits/mcp: patch`. Patch is appropriate because this corrects misleading/duplicated output within the current minor and adds one structured truth field without removing or redefining existing fields. The 0.11.0 precedent is not comparable: it added public `quick_start`/configuration APIs and a deprecation path; the closer 0.6.4 agent-facing search/recovery change was a patch. Retain patch/patch. -Phase 1b changes CLI terminal presentation only, so add a separate fragment with -`githits: patch` and `@githits/mcp: none` unless implementation evidence shows it also -changes MCP package behavior. Do not edit `CHANGELOG.md` or package versions outside -release preparation. +The formatter ownership correction is part of that same user-visible search-output +fix, so it does not add a second fragment. Do not edit `CHANGELOG.md` or package +versions outside release preparation. Phase 2 will add its own fragment. Expected impact is `githits: patch` and `@githits/mcp: none` if it changes only CLI ANSI styling; re-evaluate if shared MCP @@ -432,26 +435,23 @@ text changes. ### Phase 1a — Shared semantics and MCP text become outcome-first -- Status: **COMPLETE** (implemented, verified, and clean-reviewed; awaiting draft - PR/merge) +- Status: **COMPLETE** - Delivered: structured search payloads preserve actual partialness, one pure model owns lifecycle/availability/trust/action decisions, and MCP `text-v1` clearly states what was returned without duplicate lifecycle prose. Source provenance keeps explicit searched-target context separate from served/contributor identity. -- Verification: see `Final Phase 1a evidence` above. No major Phase 1a item is +- Verification: see `Final integrated Phase 1 evidence` above. No major Phase 1a item is deferred and no Phase 1a TODO remains. -- Next dependency: Phase 1b starts after this phase merges and reorientation checks - the shared model against current `origin/main`. ### Phase 1b — CLI search output gains the same hierarchy and useful color -- Status: **PENDING ON PHASE 1A** -- Expected outcome: the reported CLI case fits in one short screenful, full/interim/ - partial states use the shared decisions, and color emphasizes status and action. -- Assumptions: Phase 1a's model is sufficient without CLI-only semantic fields. -- Unknowns or product decisions: none. -- Dependencies: Phase 1a merged and reorientation confirms the shared model contract. -- Acceptance criteria: the CLI portions of the detailed criteria below. +- Status: **COMPLETE** (final integrated verification/review pending) +- Delivered: CLI search/search-status invoke the same shared formatter as MCP; + callers vary only ANSI and command dialect. The reported active-empty case is + concise, CLI actions are directly executable, and 780 lines of duplicated private + CLI formatting were deleted. +- Verification: targeted CLI/shared/status tests, ANSI-stripped parity, typecheck, + lint, and format checks pass. Full gates and follow-up review remain below. ### Phase 2 — Proven terminal hierarchy becomes consistent across commands @@ -474,11 +474,11 @@ text changes. ### Expected outcome -Phase 1a delivered correct, compact MCP text and structured JSON. Phase 1b remains -responsible for making the reported CLI case fit in one short screenful. Interim, -actual partial, completed, stale/provisional, terminal, and unknown cases use the -same hierarchy. CLI and MCP text cannot independently reintroduce duplicate -lifecycle prose because they consume one shared semantic projection. +Phase 1 delivers correct structured truth plus one shared compact formatter for CLI +and MCP. Interim, actual partial, completed, stale/provisional, terminal, and unknown +cases use the same hierarchy. The surfaces cannot independently reintroduce duplicate +lifecycle prose because both presentation decisions and final text layout have one +owner. ### Likely affected components @@ -490,10 +490,8 @@ lifecycle prose because they consume one shared semantic projection. from current prose helpers - `packages/mcp/src/internal.ts` - `src/commands/search.ts` -- likely new `src/commands/search-terminal.ts` -- search-specific semantic color wrappers in `src/commands/search-terminal.ts`; keep - CLI-only roles out of the MCP package and touch `packages/mcp/src/shared/colors.ts` - only if an existing primitive is genuinely missing +- `packages/mcp/src/shared/follow-up-command-text.ts` for surface-native commands +- existing shared color primitives; no CLI-only renderer or new framework - colocated response, presentation, renderer, tool, command, parity, smoke, and color tests - implementation documentation and the phase-specific changes fragments @@ -511,31 +509,14 @@ boundary uses `searchTarget` for searched package context, keeps `target` as ser contributor identity, and retains requested/fresh/served divergence only in progress and trust facts. No further Phase 1a execution is pending. -#### Phase 1b — CLI renderer and color hierarchy - -1. Extract the CLI terminal formatter from command orchestration so tests can pass an - explicit color mode and terminal width without mocking I/O. Render the same model - with CLI-native commands and the defined color hierarchy. Keep `searchAction()` and - `searchStatusAction()` responsible only for request orchestration, JSON selection, - and printing. -2. Add structural CLI tests for section order, single occurrence of each state fact, - bounded alternatives, no-result versus no-snapshot wording, terminal handling, - color roles, `NO_COLOR`, and narrow terminals. Avoid broad brittle snapshots. -3. Add one table-driven decision-parity test that feeds the same state fixtures through - both renderer adapters, asserting agreement on availability, exact lifecycle, - trust-limit set, and action kind/reference while allowing surface-native prose and - command syntax. -4. Strengthen CLI smoke with structural assertions that cannot pass merely because the - output contains `search`: the first non-spinner line is an outcome headline, a - lifecycle headline is not followed by a duplicate `status:` line, and any - `searchRef` appears only in the `Next:` action. -5. Update permanent CLI documentation and add the Phase 1b release fragment. Do not - edit versions, generated plugin assets, or changelogs. -6. Run the verification suite and inspect the actual color/no-color output for the - supplied regression fixture at representative narrow and wide terminal widths. -7. Run targeted agent evaluation for search lifecycle workloads and inspect - `tool-calls.json` and `final.json` for continuation accuracy, duplicate guidance, - token use, `toolIssues`, and `instructionIssues`. +#### Phase 1b — shared CLI/MCP formatter and color hierarchy (complete) + +The CLI now passes its payload to `renderUnifiedSearchSuccess()` or +`renderUnifiedSearchStatusText()` with `useColors` and `actionSyntax: "cli"`. +MCP uses the same functions with no color and MCP action syntax. Shared tests prove +the same layout with substituted continuation commands; CLI tests prove initial and +status equality for the n8n regression plus ANSI-stripped text parity. The private +CLI search/status formatter and its duplicate hit/provenance helpers were deleted. ### Edge cases and failure behavior @@ -568,8 +549,8 @@ and trust facts. No further Phase 1a execution is pending. ### Phase 1a and 1b acceptance criteria -The MCP/JSON criteria below are verified by Phase 1a. Criteria explicitly naming -CLI terminal output, color, or CLI smoke remain pending Phase 1b. +Implementation criteria below are verified by targeted tests. Full integrated gates, +CLI smoke, and follow-up review remain pending at this checkpoint. - The n8n-shaped active empty-snapshot CLI fixture starts with indexing, contains one readiness summary, distinguishes waiting from available-but-unsearched evidence, @@ -587,24 +568,25 @@ CLI terminal output, color, or CLI smoke remain pending Phase 1b. or an atomic interim snapshot partial. - No-snapshot states never claim zero hits; completed empty snapshots never imply sources were searched when they were not. -- CLI and MCP text share the same availability/lifecycle/trust/action decisions and - differ only in human styling, result anatomy, and command syntax. -- One table-driven dual-renderer test enforces those shared decisions for every state - fixture rather than relying only on separate renderer suites. +- CLI and MCP invoke the same formatter and differ only in ANSI enablement and + surface-native command syntax. +- Shared-renderer parity tests substitute the surface action and assert the remaining + text is identical; CLI tests assert search/status equality for the n8n fixture. - `--json` and `format: "json"` remain equal and add the exact `partialResults` Boolean; full diagnostic fields and alternative lists remain available. - Active states have one continuation action; terminal/unknown states obey existing conservative no-polling rules. -- MCP text retains the three documented anti-repeat directives and all four conditional +- Shared text retains the three documented anti-repeat directives and all four conditional empty-result pivot-suppression rules. -- CLI status hierarchy remains readable with colors disabled and at narrow width. +- CLI status hierarchy remains readable with colors disabled, and ANSI-stripped color + output is identical to no-color output. - Explicit tests cover all listed states and the existing targeted baseline remains green after updated expectations. - Required unit, parity, smoke, build, package-validation, and qualitative agent checks pass or any environment-only limitation is reported with exact evidence. - CLI smoke structurally verifies the outcome-first headline, absence of duplicate `status:` prose, and single action-contained `searchRef` when continuation exists. -- Permanent docs and each increment's independent changes fragment match implemented +- Permanent docs and the cohesive Phase 1 changes fragment match implemented behavior. ### Verification @@ -633,10 +615,7 @@ proportionate gates. ## Phase-boundary reorientation -After Phase 1a merges, re-check current `origin/main` and reorient Phase 1b. Confirm -the shared presentation contract requires no CLI-only semantic additions; export the -presentation model through `@githits/mcp/internal` only if the CLI renderer needs it. -After Phase 1b merges, run `$next-steps` before detailing Phase 2. Record observed color/no-color +After Phase 1 merges, run `$next-steps` before detailing Phase 2. Record observed color/no-color output, accepted/rejected UX rules, test/eval evidence, and any command-specific exceptions. Then inventory the remaining formatter call sites using those proven rules, select the smallest coherent command cohort, and add exact files and test tactics for From 19ce936dfd9fdad166fa9278a4f3ae129a912f5f Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:31:44 +0300 Subject: [PATCH 26/46] test: strengthen CLI search smoke contract Assert outcome-first search text, hide internal lifecycle metadata, and require CLI-native follow-up actions in the live product smoke. --- scripts/cli-smoke.ts | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 1e593c6a..56a2871a 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -364,6 +364,42 @@ function assertTerminalOutput(result: CommandResult, context: string): string { return text; } +function assertSearchTerminalText(text: string, context: string): void { + const lines = text.split("\n"); + const firstLine = lines[0]?.trim() ?? ""; + assert(firstLine.length > 0, `${context}: missing outcome first line`); + assert( + !firstLine.startsWith("Warning:") && + !firstLine.startsWith("search |") && + !firstLine.startsWith("search_status |"), + `${context}: non-outcome text precedes search outcome`, + ); + assert( + !lines.some((line) => /^status\s*:/i.test(line.trim())), + `${context}: duplicated lifecycle status line`, + ); + assert(!text.includes("searchRef:"), `${context}: leaked searchRef detail`); + assert(!text.includes("indexingRef"), `${context}: leaked indexingRef`); + assert( + !text.includes("freshnessReason"), + `${context}: leaked freshnessReason`, + ); + + const statusActions = lines.filter((line) => + line.startsWith("Next: githits search-status "), + ); + assert( + statusActions.length <= 1, + `${context}: expected at most one search-status action`, + ); + assert( + text.includes("githits code read") || + text.includes("githits docs read") || + statusActions.length === 1, + `${context}: missing CLI-native result or status follow-up`, + ); +} + function assertJsonOutput(result: CommandResult, context: string): unknown { assert( result.exitCode === 0, @@ -1409,10 +1445,7 @@ async function runLiveSmoke(env: Record): Promise { ]), "search terminal", ); - assert( - searchText.includes("search") || searchText.includes("result"), - "search terminal missing result context", - ); + assertSearchTerminalText(searchText, "search terminal"); const searchJson = assertJsonOutput( await runCli([ From 49e495b2d83a7281162ffd189af857d823ecc8ab Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:36:26 +0300 Subject: [PATCH 27/46] fix: keep search pagination surface-native Render executable CLI pagination flags while preserving MCP parameter syntax, and remove the obsolete formatter helpers left behind by the shared-renderer migration. --- docs/implementation/tools.md | 2 + .../src/shared/unified-search-text.test.ts | 56 +- .../mcp/src/shared/unified-search-text.ts | 498 +----------------- 3 files changed, 51 insertions(+), 505 deletions(-) diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 9893895a..21a892c2 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -301,6 +301,8 @@ More hits available. Pass offset=N for the next page or limit=N to widen. ``` `` compacts to `code` / `symbol` / `docs` / `repo-docs`. `` is a ready-to-call follow-up when possible. MCP uses `code_read target="npm:pkg@version" path="..." start_line=N end_line=M` or `docs_read page_id="..."`; CLI uses the equivalent `githits code read ... --lines N-M` or `githits docs read ...`. If a code/symbol hit lacks a file path, text mode prints `follow-up unavailable: missing filePath` rather than fabricating a path. +Pagination follows the same dialect rule: MCP uses `offset=N` / `limit=N`, while +CLI uses `--offset N` / `--limit N`. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index ac166282..03fd69fb 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -1033,37 +1033,43 @@ describe("renderUnifiedSearchSuccess", () => { }); it("bounds alternatives and preserves pagination and result ordering", () => { - const text = renderUnifiedSearchSuccess( - completed([codeHit(), docsHit()], { - hasMore: true, - nextOffset: 10, - sourceStatus: [ - source({ - targetLabel: "npm:express", - targetResolution: { - availableVersions: [ - { version: "5.2.1", ref: "v5.2.1" }, - { version: "5.2.0", ref: "v5.2.0" }, - { version: "5.1.0", ref: "v5.1.0" }, - { version: "5.0.0", ref: "v5.0.0" }, - ], - availableRefs: [ - { ref: "HEAD" }, - { ref: "main" }, - { ref: "next" }, - { ref: "dev" }, - ], - }, - }), - ], - }), - ); + const payload = completed([codeHit(), docsHit()], { + hasMore: true, + nextOffset: 10, + sourceStatus: [ + source({ + targetLabel: "npm:express", + targetResolution: { + availableVersions: [ + { version: "5.2.1", ref: "v5.2.1" }, + { version: "5.2.0", ref: "v5.2.0" }, + { version: "5.1.0", ref: "v5.1.0" }, + { version: "5.0.0", ref: "v5.0.0" }, + ], + availableRefs: [ + { ref: "HEAD" }, + { ref: "main" }, + { ref: "next" }, + { ref: "dev" }, + ], + }, + }), + ], + }); + const text = renderUnifiedSearchSuccess(payload); + const cliText = renderUnifiedSearchSuccess(payload, { + actionSyntax: "cli", + }); expect(text).toContain("[1] cline/cline@v3.4.2"); expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); expect(text).toContain( "Indexed alternatives: versions 5.2.1, 5.2.0, 5.1.0 +1 more; refs HEAD, main,\nnext +1 more", ); expect(text).toContain("More hits available. Pass offset=10"); + expect(cliText).toContain( + "More hits available. Pass --offset 10 or --limit N to widen.", + ); + expect(cliText).not.toContain("Pass offset=10"); expect(text).not.toContain("v5.0.0"); expect(text).not.toContain("dev"); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 04b01174..aacef6b8 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -19,13 +19,6 @@ import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { colors, dim, highlight, highlightRanges } from "./colors.js"; import { buildSearchHitFollowUpCommand } from "./follow-up-command-text.js"; -import { isHealthySearchLifecycleState } from "./search-lifecycle.js"; -import { - buildResolutionFromRetryCandidates, - buildTargetResolutionNotes, - formatTargetResolutionIdentity, - type LeanTargetResolution, -} from "./target-resolution.js"; import { projectUnifiedSearchPresentation, type UnifiedSearchAction, @@ -38,12 +31,9 @@ import { } from "./unified-search-presentation.js"; import type { UnifiedSearchCompletedPayload, - UnifiedSearchDocumentationContributorPayload, UnifiedSearchErrorPayload, UnifiedSearchHitPayload, UnifiedSearchIncompletePayload, - UnifiedSearchQueryEcho, - UnifiedSearchSourceStatusPayload, } from "./unified-search-response.js"; const SUMMARY_WRAP_WIDTH = 76; @@ -109,10 +99,10 @@ export function renderUnifiedSearchPresentationText( if (presentation.hasMore) { if (lines[lines.length - 1] !== "") lines.push(""); - const nextOffsetHint = - typeof result.nextOffset === "number" - ? `More hits available. Pass offset=${result.nextOffset} or limit=N to widen.` - : "More hits available. Pass limit=N to widen."; + const nextOffsetHint = formatPaginationHint( + result.nextOffset, + settings.actionSyntax, + ); lines.push(nextOffsetHint); } @@ -122,6 +112,20 @@ export function renderUnifiedSearchPresentationText( return lines.join("\n"); } +function formatPaginationHint( + nextOffset: number | undefined, + actionSyntax: "mcp" | "cli", +): string { + if (actionSyntax === "cli") { + return typeof nextOffset === "number" + ? `More hits available. Pass --offset ${nextOffset} or --limit N to widen.` + : "More hits available. Pass --limit N to widen."; + } + return typeof nextOffset === "number" + ? `More hits available. Pass offset=${nextOffset} or limit=N to widen.` + : "More hits available. Pass limit=N to widen."; +} + interface NormalizedTextOptions { useColors: boolean; actionSyntax: "mcp" | "cli"; @@ -832,167 +836,6 @@ function formatLineRange(start?: number, end?: number): string { return `:${start}-${end}`; } -export interface DocumentationSourceResult { - target: string; -} - -/** Render compact references for healthy docs and explain only exceptions. */ -export function appendDocumentationSources( - lines: string[], - sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, - results: DocumentationSourceResult[] = [], -): void { - const documented = - sourceStatus?.filter((entry) => entry.contributors?.length) ?? []; - if (documented.length === 0) return; - if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push(""); - - const entries = documented.map((entry) => { - const contributors = entry.contributors ?? []; - const sources = contributors.map((contributor) => ({ - contributor, - identity: formatDocumentationContributorIdentity( - contributor, - contributors, - ), - })); - return { - entry, - sources, - healthy: contributors.every(isHealthyDocumentationContributor), - }; - }); - const healthy = entries.filter((entry) => entry.healthy); - const exceptional = entries.filter((entry) => !entry.healthy); - const responseTargets = new Set([ - ...(sourceStatus?.map((entry) => entry.targetLabel) ?? []), - ...results.map((result) => result.target), - ]); - const showTargets = responseTargets.size > 1; - - if (healthy.length > 0) { - if (showTargets) { - lines.push("searched:"); - for (const { entry, sources } of healthy) { - lines.push( - ` ${entry.targetLabel}: ${sources.map(({ identity }) => identity).join("; ")}`, - ); - } - } else { - lines.push( - `searched: ${healthy.flatMap(({ sources }) => sources.map(({ identity }) => identity)).join("; ")}`, - ); - } - } - - if (healthy.length > 0 && exceptional.length > 0) lines.push(""); - - if (exceptional.length > 0) { - lines.push("documentation sources:"); - for (const { entry, sources } of exceptional) { - if (showTargets) lines.push(` ${entry.targetLabel}:`); - const indent = showTargets ? " " : " "; - for (const { contributor, identity } of sources) { - lines.push( - `${indent}- ${formatDocumentationContributor(contributor, identity)}`, - ); - } - } - } -} - -function formatDocumentationContributor( - contributor: NonNullable< - UnifiedSearchSourceStatusPayload["contributors"] - >[number], - identity: string, -): string { - if (isHealthyDocumentationContributor(contributor)) { - return `${identity} - searched`; - } - - const details: string[] = []; - if (contributor.state === "SEARCHED") { - if (contributor.freshness === "STALE") { - details.push("searched an older snapshot"); - } else if (contributor.freshness === "PROVISIONAL") { - details.push("searched provisional index; indexing continues"); - } else { - details.push("searched"); - } - } else { - details.push(formatDocumentationContributorState(contributor.state)); - if (contributor.freshness === "STALE") { - details.push("the available snapshot is older"); - } - } - const coverage = formatPublishedCoverage(contributor.coverage); - if (coverage) { - details.push(coverage); - } else if ( - contributor.kind === "DOCPACK" && - contributor.state === "SEARCHED" && - !contributor.coverage - ) { - details.push("published coverage details unavailable"); - } - return `${identity} - ${details.join("; ")}`; -} - -function formatDocumentationContributorState( - state: UnifiedSearchDocumentationContributorPayload["state"], -): string { - switch (state) { - case "SEARCHED": - return "searched"; - case "READY": - return "available, but not searched for this response"; - case "PENDING": - return "not ready, so it was not searched"; - case "UNAVAILABLE": - return "unavailable and was not searched"; - } -} - -function formatDocumentationContributorIdentity( - contributor: NonNullable< - UnifiedSearchSourceStatusPayload["contributors"] - >[number], - contributors: UnifiedSearchDocumentationContributorPayload[], -): string { - if (contributor.kind === "REPOSITORY_DOCS") { - const identity = [contributor.repositoryUrl, contributor.commitSha] - .filter(Boolean) - .join(" @ "); - return identity ? `repo ${identity}` : "repository docs"; - } - const docpacks = contributors.filter( - (candidate) => candidate.kind === "DOCPACK", - ); - const siteIdentity = formatDocumentationSiteIdentity(contributor.siteUrl); - const collidingDocpacks = docpacks.filter( - (candidate) => - formatDocumentationSiteIdentity(candidate.siteUrl) === siteIdentity, - ); - const docpackNumber = collidingDocpacks.indexOf(contributor) + 1; - const numberSuffix = collidingDocpacks.length > 1 ? ` ${docpackNumber}` : ""; - if (siteIdentity) return `site ${siteIdentity}${numberSuffix}`; - - return `site documentation${numberSuffix}`; -} - -function isHealthyDocumentationContributor( - contributor: UnifiedSearchDocumentationContributorPayload, -): boolean { - if (contributor.state !== "SEARCHED" || contributor.freshness !== "CURRENT") { - return false; - } - return ( - contributor.kind === "REPOSITORY_DOCS" || - contributor.coverage?.coverageState === "COMPLETE" - ); -} - function formatDocumentationSiteIdentity( value: string | undefined, ): string | undefined { @@ -1007,311 +850,6 @@ function formatDocumentationSiteIdentity( } } -function formatPublishedCoverage( - coverage: NonNullable< - UnifiedSearchSourceStatusPayload["contributors"] - >[number]["coverage"], -): string | undefined { - if (!coverage) return undefined; - if (coverage.coverageState === "COMPLETE") return undefined; - - const details: string[] = []; - if (typeof coverage.pagesCrawled === "number") { - details.push( - `${coverage.pagesCrawled} page${coverage.pagesCrawled === 1 ? "" : "s"} included`, - ); - } - if ( - typeof coverage.artifactOverflowPageCount === "number" && - coverage.artifactOverflowPageCount > 0 - ) { - details.push( - `${coverage.artifactOverflowPageCount} page${coverage.artifactOverflowPageCount === 1 ? "" : "s"} omitted`, - ); - } - if ( - typeof coverage.frontierRemaining === "number" && - coverage.frontierRemaining > 0 - ) { - details.push( - `${coverage.frontierRemaining} discovered page${coverage.frontierRemaining === 1 ? "" : "s"} not included`, - ); - } - if (typeof coverage.estimatedTotalPages === "number") { - details.push(`about ${coverage.estimatedTotalPages} estimated total`); - } - - const reason = coverage.coverageReason - ? humanizeCoverageReason(coverage.coverageReason) - : undefined; - const cappedReasonIsHeadline = - coverage.coverageState === "CAPPED" && - (reason === "artifact size" || reason === "max pages"); - if (reason && !cappedReasonIsHeadline) { - details.push(`limited by ${reason}`); - } - - const detailText = details.length > 0 ? `: ${details.join(", ")}` : ""; - switch (coverage.coverageState) { - case "PARTIAL": - return `published snapshot is partial${detailText}`; - case "CAPPED": - if (reason === "artifact size") { - return `published snapshot hit its size cap${detailText}`; - } - if (reason === "max pages") { - return `published snapshot reached its page limit${detailText}`; - } - return `published snapshot is capped${detailText}`; - case "NONE": - return `published coverage was not measured${detailText}`; - default: - return `published coverage is ${coverage.coverageState.toLowerCase()}${detailText}`; - } -} - -function humanizeCoverageReason(reason: string): string { - if (reason === "trap_suspected") return "a suspected crawl trap"; - return reason.replaceAll(/[_-]+/g, " "); -} - -export function appendEmptySearchGuidance( - lines: string[], - options: { - query?: UnifiedSearchQueryEcho; - showQuery?: boolean; - sourceStatus?: UnifiedSearchCompletedPayload["sourceStatus"]; - evidenceNotice?: string; - guidanceStyle?: "mcp" | "cli"; - fallbackHeadline?: string; - }, -): void { - if (options.showQuery && options.query?.raw) { - lines.push(`query=${quote(options.query.raw)}`); - } - const hasUnsearchedSources = hasUnsearchedDocumentationSources( - options.sourceStatus, - ); - if (options.evidenceNotice) { - lines.push("No hits in the searched evidence on this page."); - lines.push("Do not repeat immediately."); - return; - } - lines.push( - hasUnsearchedSources - ? "No hits in the searched evidence on this page." - : (options.fallbackHeadline ?? - formatEmptySearchHeadline(options.sourceStatus)), - ); - if (options.guidanceStyle === "cli") { - lines.push( - hasIndexingSource(options.sourceStatus) - ? "Run again with a larger --wait while indexing finishes." - : isStandaloneSiteSearch(options.sourceStatus) - ? "Try a shorter or broader query." - : "Try a shorter or broader query, or search another source.", - ); - return; - } - lines.push("Do not repeat this search unchanged."); - if (hasIndexingSource(options.sourceStatus)) { - const hasAlternatives = options.sourceStatus?.some( - (entry) => - Boolean(entry.targetResolution?.availableVersions.length) || - Boolean(entry.targetResolution?.availableRefs.length), - ); - lines.push( - hasAlternatives - ? 'next: query an indexed version/ref labelled "queryable now", or rerun with a larger wait_timeout_ms to wait for indexing.' - : "next: rerun with a larger wait_timeout_ms to wait for indexing.", - ); - return; - } - - const pivots = ["shorten or broaden the query"]; - if (hasRestrictiveSearchFilters(options.query)) { - pivots.push("remove restrictive filters"); - } - const standaloneSiteSearch = isStandaloneSiteSearch(options.sourceStatus); - if (!standaloneSiteSearch && !options.query?.sources?.includes("symbol")) { - pivots.push('use source="symbol" for an exact API/entity name'); - } - if (!standaloneSiteSearch) { - pivots.push("use code_grep for a known literal or regex"); - } - lines.push(`next: ${pivots.join("; ")}.`); -} - -function hasUnsearchedDocumentationSources( - sourceStatus: UnifiedSearchCompletedPayload["sourceStatus"], -): boolean { - return Boolean( - sourceStatus?.some((entry) => - entry.contributors?.some( - (contributor) => contributor.state !== "SEARCHED", - ), - ), - ); -} - -// Discovery carries provisional readiness through codeIndexState or -// targetResolution; legacy indexingStatus remains INDEXING. -function hasIndexingSource( - sourceStatus: UnifiedSearchCompletedPayload["sourceStatus"], -): boolean { - return Boolean( - sourceStatus?.some( - (entry) => - entry.targetResolution?.freshness === "indexing" || - entry.targetResolution?.freshness === "provisional" || - entry.indexingStatus === "INDEXING" || - entry.codeIndexState === "INDEXING" || - entry.codeIndexState === "PROVISIONAL" || - entry.contributors?.some( - (contributor) => contributor.freshness === "PROVISIONAL", - ), - ), - ); -} - -function hasRestrictiveSearchFilters( - query: UnifiedSearchQueryEcho | undefined, -): boolean { - const filters = query?.filters; - return Boolean( - filters?.kind || - filters?.category || - filters?.pathPrefix || - filters?.fileIntent || - filters?.publicOnly === true || - (query?.raw && - /(?:^|\s)(?:kind|category|path|lang|name|intent):/i.test(query.raw)), - ); -} - -function isStandaloneSiteSearch( - sourceStatus: UnifiedSearchCompletedPayload["sourceStatus"], -): boolean { - return Boolean( - sourceStatus?.length && - sourceStatus.every((entry) => { - const resolution = entry.targetResolution; - return Boolean( - entry.targetLabel.startsWith("site:") || - resolution?.requested?.site || - resolution?.resolvedRequested?.site || - resolution?.served?.site, - ); - }), - ); -} - -function formatEmptySearchHeadline( - sourceStatus: UnifiedSearchCompletedPayload["sourceStatus"], -): string { - if (!sourceStatus || sourceStatus.length === 0) return "No hits."; - if (sourceStatus.length > 1) { - const sources = Array.from( - new Set(sourceStatus.map((entry) => entry.source)), - ).join(", "); - return `No hits from any source (${sources}).`; - } - - const entry = sourceStatus[0]; - if (!entry) return "No hits."; - const served = - entry.servedTarget ?? - formatTargetResolutionIdentity(entry.targetResolution?.served) ?? - entry.targetLabel; - const requested = - entry.requestedTarget ?? - formatTargetResolutionIdentity(entry.targetResolution?.requested); - // STALE is headline-worthy provenance even when it is not warning-worthy. - const unhealthyIndexState = [entry.indexingStatus, entry.codeIndexState].find( - (state) => state && !isHealthySearchLifecycleState(state), - ); - const freshness = - unhealthyIndexState ?? - entry.targetResolution?.freshness ?? - entry.codeIndexState ?? - entry.indexingStatus; - const context: string[] = []; - if (requested && requested !== served) context.push(`requested ${requested}`); - if (freshness) context.push(describeFreshness(freshness)); - const suffix = context.length > 0 ? ` (${context.join("; ")})` : ""; - return `No hits for ${entry.source} on ${served}${suffix}.`; -} - -export function formatProgressTarget(target: { - requested?: string; - resolvedRequested?: string; - served?: string; - freshness?: string; - indexingRef?: string; - requestedRefKind?: string; - targetResolution?: LeanTargetResolution; - availableVersions?: Array<{ version?: string; ref: string }>; - availableRefs?: Array<{ version?: string; ref: string }>; - suggestedRefs?: Array<{ version?: string; ref: string }>; -}): string { - const parts: string[] = []; - if (target.requested) parts.push(`requested=${target.requested}`); - if (target.resolvedRequested) parts.push(`fresh=${target.resolvedRequested}`); - if (target.served) parts.push(`served=${target.served}`); - if (target.freshness) - parts.push(`state=${describeFreshness(target.freshness)}`); - if (target.requestedRefKind) parts.push(`intent=${target.requestedRefKind}`); - if (target.indexingRef) parts.push(`indexingRef=${target.indexingRef}`); - for (const note of buildTargetResolutionNotes( - target.targetResolution ?? buildResolutionFromRetryCandidates(target), - )) { - parts.push(note); - } - return parts.length > 0 ? parts.join(SEP) : "target progress unavailable"; -} - -function describeFreshness(value: string): string { - switch (value) { - case "PENDING": - return "pending"; - case "INDEXING": - return "indexing"; - case "PROVISIONAL": - return "provisional (still indexing)"; - case "STALE": - return "previous-snapshot"; - case "CURRENT": - case "INDEXED": - return "current"; - default: - return value.toLowerCase(); - } -} - -/** Render replayable standalone-site recovery guidance from structured fields. */ -export function formatSuggestedSiteTargetGuidance(entry: { - suggestedSiteTargets?: string[]; - suggestedSiteTargetsTruncated?: boolean; -}): string[] { - const lines: string[] = []; - if (entry.suggestedSiteTargets?.length) { - lines.push( - `Suggested site targets: ${entry.suggestedSiteTargets.join(", ")}`, - ); - } - if (entry.suggestedSiteTargetsTruncated) { - lines.push("Additional site targets were omitted."); - } - return lines; -} - -function quote(value: string): string { - // Use single quotes when the value already contains a double quote; - // agents read either form. JSON-escape would be over-engineering for - // a header. - return value.includes('"') ? `'${value}'` : `"${value}"`; -} - function formatDetailValue(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; From e1c0e0050b4ecc4ce522c85580b17378bcc3fd19 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:41:36 +0300 Subject: [PATCH 28/46] test: lock CLI search text dialect Unit-test the live CLI search smoke predicate, reject MCP pagination syntax, and document pagination as an intentional surface-native difference. --- docs/implementation/mcp-cli-parity.md | 8 ++++---- scripts/cli-smoke.ts | 9 ++++++++- scripts/smoke-scripts.test.ts | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index d7201b0c..4565c349 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -258,9 +258,9 @@ only in the exact continuation action. MCP renders `Next: githits search-status ... --wait ...`. Terminal and unknown responses instead give an explicit transport-neutral no-poll instruction. -CLI supplies ANSI enablement and CLI-native follow-up commands to the shared -formatter; MCP supplies no color and MCP-native tool-call syntax. Hierarchy, -wording, wrapping, and fact selection are otherwise identical. Search-result +CLI supplies ANSI enablement and CLI-native follow-up and pagination syntax to the +shared formatter; MCP supplies no color and MCP-native tool-call syntax. Hierarchy, +wrapping, and fact selection are otherwise identical. Search-result follow-ups likewise render as `githits code read` / `githits docs read` in CLI and `code_read` / `docs_read` in MCP. ANSI-stripped CLI output is structurally identical to no-color output. @@ -273,7 +273,7 @@ payloads with no result snapshot omit that field. Full `warnings[]`, source diagnostics, evidence notices, reason codes, references, and alternative lists remain available in JSON even when MCP text classifies or bounds them for readability. The shared JSON parity tests compare these envelopes deeply; only -surface-native follow-up syntax and ANSI differ. +surface-native follow-up and pagination syntax plus ANSI differ. ### `PARITY-ERROR-ENVELOPE` diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 56a2871a..33e0e978 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -364,7 +364,7 @@ function assertTerminalOutput(result: CommandResult, context: string): string { return text; } -function assertSearchTerminalText(text: string, context: string): void { +export function assertSearchTerminalText(text: string, context: string): void { const lines = text.split("\n"); const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); @@ -392,6 +392,13 @@ function assertSearchTerminalText(text: string, context: string): void { statusActions.length <= 1, `${context}: expected at most one search-status action`, ); + const paginationLines = lines.filter((line) => + line.startsWith("More hits available."), + ); + assert( + !paginationLines.some((line) => /\b(?:offset|limit)=/.test(line)), + `${context}: MCP pagination syntax leaked into CLI output`, + ); assert( text.includes("githits code read") || text.includes("githits docs read") || diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 779431ad..20cb70d5 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -6,6 +6,7 @@ import { EXPECTED_MCP_TOOLS } from "@githits/mcp/smoke-test"; import { assertExperimentalCliResolveText, assertRootHelpStructure, + assertSearchTerminalText, buildMcpParityCommand, EXPECTED_EXPERIMENTAL_TOP_LEVEL_COMMANDS, EXPECTED_STABLE_TOP_LEVEL_COMMANDS, @@ -23,6 +24,25 @@ import { } from "./mcp-smoke.ts"; import { toStdioLaunch } from "./smoke-launch-target.ts"; +describe("CLI search smoke contract", () => { + const valid = `1 result from npm:express@5.2.1 +githits code read 'npm:express@5.2.1' 'lib/application.js' --lines 1-10 +More hits available. Pass --offset 10 or --limit N to widen.`; + + it("accepts outcome-first text with CLI-native actions", () => { + expect(() => assertSearchTerminalText(valid, "search")).not.toThrow(); + }); + + it.each([ + [`Warning: indexing\n${valid}`, "non-outcome text"], + [`${valid}\nstatus: indexing`, "lifecycle status"], + [valid.replace("--offset 10", "offset=10"), "MCP pagination syntax"], + ["1 result from npm:express@5.2.1", "missing CLI-native"], + ])("rejects invalid search text", (text, message) => { + expect(() => assertSearchTerminalText(text, "search")).toThrow(message); + }); +}); + describe("smoke script options", () => { const tempDirs: string[] = []; From 9157eae93c6109c4ef758ebb6b317aae1771de21 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:45:31 +0300 Subject: [PATCH 29/46] test: accept terminal search smoke outcomes Keep the structural smoke strict while allowing completed-empty and terminal responses that provide an explicit next action. --- docs/implementation/mcp-cli-parity.md | 3 ++- scripts/cli-smoke.ts | 4 ++-- scripts/smoke-scripts.test.ts | 14 +++++++++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 4565c349..b889444c 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -260,7 +260,8 @@ instead give an explicit transport-neutral no-poll instruction. CLI supplies ANSI enablement and CLI-native follow-up and pagination syntax to the shared formatter; MCP supplies no color and MCP-native tool-call syntax. Hierarchy, -wrapping, and fact selection are otherwise identical. Search-result +wrapping, fact selection, and wording apart from those syntax differences are +otherwise identical. Search-result follow-ups likewise render as `githits code read` / `githits docs read` in CLI and `code_read` / `docs_read` in MCP. ANSI-stripped CLI output is structurally identical to no-color output. diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 33e0e978..944e3455 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -402,8 +402,8 @@ export function assertSearchTerminalText(text: string, context: string): void { assert( text.includes("githits code read") || text.includes("githits docs read") || - statusActions.length === 1, - `${context}: missing CLI-native result or status follow-up`, + lines.some((line) => line.startsWith("Next: ")), + `${context}: missing result follow-up or next action`, ); } diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 20cb70d5..18aafaec 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -31,13 +31,25 @@ More hits available. Pass --offset 10 or --limit N to widen.`; it("accepts outcome-first text with CLI-native actions", () => { expect(() => assertSearchTerminalText(valid, "search")).not.toThrow(); + expect(() => + assertSearchTerminalText( + "No results returned from npm:express\nNext: shorten or broaden query; use githits code grep.", + "search", + ), + ).not.toThrow(); + expect(() => + assertSearchTerminalText( + "FAILED - no results returned\nNext: rerun search later.", + "search", + ), + ).not.toThrow(); }); it.each([ [`Warning: indexing\n${valid}`, "non-outcome text"], [`${valid}\nstatus: indexing`, "lifecycle status"], [valid.replace("--offset 10", "offset=10"), "MCP pagination syntax"], - ["1 result from npm:express@5.2.1", "missing CLI-native"], + ["1 result from npm:express@5.2.1", "missing result follow-up"], ])("rejects invalid search text", (text, message) => { expect(() => assertSearchTerminalText(text, "search")).toThrow(message); }); From 79afea3af8635cd9c793f29d3b4b8273b5bc12a9 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:47:01 +0300 Subject: [PATCH 30/46] docs: close shared search formatter verification Record the final test, smoke, package-validation, size, and retained review evidence for the completed CLI/MCP formatter increment. --- docs/plans/search-output-ux.md | 39 +++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/plans/search-output-ux.md b/docs/plans/search-output-ux.md index 62d848bc..4f941847 100644 --- a/docs/plans/search-output-ux.md +++ b/docs/plans/search-output-ux.md @@ -5,7 +5,7 @@ - Overall: **IN PROGRESS** - Phase 1a: **COMPLETE** - Phase 1b: **COMPLETE** (implemented in the same draft PR after the formatter - ownership correction; final integrated verification/review pending) + ownership correction) - Phase 2: **PENDING** ## Problem and expected outcome @@ -73,22 +73,27 @@ When this work is complete: ### Final integrated Phase 1 evidence -- `bun test`: 3,364 tests passed, 0 failed, 10,825 expects across 184 files. +- `bun test`: 3,371 tests passed, 0 failed, 10,871 expects across 184 files. - `bun run typecheck`: clean; format and lint checked 437 files clean. - Root and `packages/mcp` builds passed on merged `origin/main`. - `bun run validate:packages` and `bun run validate:packages:mcp-publish` passed; the publish dry-run was skipped because `@githits/mcp@0.11.0` is already published. -- Source `bun run smoke:cli` and `bun run smoke:mcp` passed on merged `origin/main`. +- Source `bun run smoke:cli` and `bun run smoke:mcp` passed sequentially on the + final formatter state: 89 CLI steps and 46 MCP steps. An earlier parallel attempt + hit the backend rate limit in the unrelated `get_example` step; its evidence was + preserved and both suites passed after the rate window cleared. - Targeted `unified-search-investigation` agent E2E succeeded with both Claude and Codex; usefulness was helped/high confidence. The discovered symbol-label bug was fixed. -- The final focused shared/status/tool cohort passed 123 tests with 0 failures. -- Production/shared-smoke delta across the seven counted source files is 1,683 - additions and 1,276 deletions (net +407). The single-formatter correction crossed +- The final focused shared/status/tool/CLI cohort passed 149 tests with 0 failures + and 584 assertions before the smoke-contract unit cases were added. +- Production/shared-smoke delta across the seven counted source files is 1,679 + additions and 1,753 deletions (net -74). The single-formatter correction crossed the addition-only caution threshold but deleted 818 lines from the CLI command and - removed the duplicated formatter instead of adding another layer. The user - explicitly authorized the root-cause correction even if it grew this PR. + removed the obsolete shared helper block instead of retaining two apparent + implementations. The user explicitly authorized the root-cause correction even if + it grew this PR. - `origin/main` at `739ec4e` was merged cleanly with no conflicts. Overlapping permanent documentation auto-merged, and integrated full-test, build, package, and source-smoke verification passed. @@ -445,13 +450,13 @@ text changes. ### Phase 1b — CLI search output gains the same hierarchy and useful color -- Status: **COMPLETE** (final integrated verification/review pending) +- Status: **COMPLETE** - Delivered: CLI search/search-status invoke the same shared formatter as MCP; callers vary only ANSI and command dialect. The reported active-empty case is concise, CLI actions are directly executable, and 780 lines of duplicated private CLI formatting were deleted. -- Verification: targeted CLI/shared/status tests, ANSI-stripped parity, typecheck, - lint, and format checks pass. Full gates and follow-up review remain below. +- Verification: targeted CLI/shared/status tests, ANSI-stripped parity, full tests, + builds, package validation, source smoke, and retained Opus review all pass. ### Phase 2 — Proven terminal hierarchy becomes consistent across commands @@ -549,8 +554,8 @@ CLI search/status formatter and its duplicate hit/provenance helpers were delete ### Phase 1a and 1b acceptance criteria -Implementation criteria below are verified by targeted tests. Full integrated gates, -CLI smoke, and follow-up review remain pending at this checkpoint. +Implementation criteria below are verified by targeted and integrated tests, both +source smoke suites, and the completed follow-up review. - The n8n-shaped active empty-snapshot CLI fixture starts with indexing, contains one readiness summary, distinguishes waiting from available-but-unsearched evidence, @@ -639,8 +644,12 @@ after Phase 1b rather than retaining a stale future-work artifact. fixed. The initial Opus loop findings were also fixed; that loop exposed the overloaded source-target identity later corrected by the explicit `searchTarget` boundary. -- The user selected that root-cause boundary correction. A fresh follow-up Opus loop - ended clean with no findings and nothing deferred. +- The user selected that root-cause boundary correction. Retained follow-up Opus + rounds found and closed the CLI pagination dialect leak, obsolete formatter helper + block, missing smoke-predicate unit coverage, and stale parity wording. The final + round was clean. Its two non-blocking observations were also fixed inline: the + smoke predicate now accepts legitimate completed-empty and terminal actions, and + the parity wording states the exact syntax exceptions. - Repository policy prevented a second internal `code_reviewer`: this session had already used its one allowed reviewer for the technical plan. - Rejected remedy: do not issue a fresh live search to capture transient JSON. The From 7d4dbbdc15f26808e0553c13d0226d2fd7649fe5 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 27 Aug 2026 17:52:48 +0300 Subject: [PATCH 31/46] docs: record latest main integration Update final verification, package-version, delta-size, and conflict-resolution evidence after integrating the current main branch. --- docs/plans/search-output-ux.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/plans/search-output-ux.md b/docs/plans/search-output-ux.md index 4f941847..414d5a5b 100644 --- a/docs/plans/search-output-ux.md +++ b/docs/plans/search-output-ux.md @@ -73,11 +73,11 @@ When this work is complete: ### Final integrated Phase 1 evidence -- `bun test`: 3,371 tests passed, 0 failed, 10,871 expects across 184 files. +- `bun test`: 3,380 tests passed, 0 failed, 10,869 expects across 184 files. - `bun run typecheck`: clean; format and lint checked 437 files clean. - Root and `packages/mcp` builds passed on merged `origin/main`. - `bun run validate:packages` and `bun run validate:packages:mcp-publish` passed; - the publish dry-run was skipped because `@githits/mcp@0.11.0` is already + the publish dry-run was skipped because `@githits/mcp@0.11.1` is already published. - Source `bun run smoke:cli` and `bun run smoke:mcp` passed sequentially on the final formatter state: 89 CLI steps and 46 MCP steps. An earlier parallel attempt @@ -88,15 +88,16 @@ When this work is complete: bug was fixed. - The final focused shared/status/tool/CLI cohort passed 149 tests with 0 failures and 584 assertions before the smoke-contract unit cases were added. -- Production/shared-smoke delta across the seven counted source files is 1,679 - additions and 1,753 deletions (net -74). The single-formatter correction crossed - the addition-only caution threshold but deleted 818 lines from the CLI command and - removed the obsolete shared helper block instead of retaining two apparent - implementations. The user explicitly authorized the root-cause correction even if - it grew this PR. -- `origin/main` at `739ec4e` was merged cleanly with no conflicts. Overlapping - permanent documentation auto-merged, and integrated full-test, build, package, - and source-smoke verification passed. +- Production delta across the six runtime source files is 1,656 additions and 1,742 + deletions (net -86); the two source smoke harnesses add 81 lines and delete 10. + The single-formatter correction crossed the addition-only caution threshold but + deleted 818 lines from the CLI command and removed the obsolete shared helper + block instead of retaining two apparent implementations. The user explicitly + authorized the root-cause correction even if it grew this PR. +- `origin/main` at `7981d07` was integrated. One textual conflict in the permanent + tools document was resolved by retaining both the new code-navigation context-cap + guidance and this increment's unified-search contract. Integrated full-test, + build, package, and source-smoke verification passed. - Built smoke suites were not required: smoke launch and CI product-validation behavior did not change. From d93d36ac624eb833d0ef46895f4dfe9248bf0f2e Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 00:21:38 +0300 Subject: [PATCH 32/46] fix: group search readiness by target Render CLI and MCP search status through target-centric blocks with compact session actions. Preserve exact aliases for grouping and strengthen smoke coverage for the shared hierarchy. --- changes/search-output-hierarchy.changed.md | 2 +- docs/implementation/cli-commands.md | 26 +- docs/implementation/mcp-cli-parity.md | 76 +- docs/implementation/tools.md | 91 ++- docs/plans/search-output-ux.md | 659 ------------------ .../unified-search-presentation.test.ts | 316 ++++++++- .../src/shared/unified-search-presentation.ts | 208 +++++- .../shared/unified-search-status-text.test.ts | 35 +- .../src/shared/unified-search-text.test.ts | 240 ++++--- .../mcp/src/shared/unified-search-text.ts | 411 +++++------ packages/mcp/src/smoke-test.test.ts | 208 +++++- packages/mcp/src/smoke-test.ts | 130 +++- packages/mcp/src/tools/search-status.test.ts | 65 +- packages/mcp/src/tools/search.test.ts | 4 +- scripts/cli-smoke.ts | 140 +++- scripts/smoke-scripts.test.ts | 169 ++++- src/commands/search.test.ts | 280 +++++--- 17 files changed, 1837 insertions(+), 1223 deletions(-) delete mode 100644 docs/plans/search-output-ux.md diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md index e5c3d9c1..fb950dcc 100644 --- a/changes/search-output-hierarchy.changed.md +++ b/changes/search-output-hierarchy.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Clarify unified search output** - Add exact partial-result truth to JSON and route CLI and MCP search/search-status through one outcome-first formatter with concise lifecycle, readiness, provenance, ANSI hierarchy, and surface-native continuation guidance. +- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with target-grouped readiness, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 5c0e5b79..ec58c5d1 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,7 +2,7 @@ ## Purpose -The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. `resolve` and `code diff` are experimental, host-config-gated commands. MCP-parity commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. +The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. `resolve` and `code diff` are experimental, host-config-gated commands. MCP-parity commands share business logic with the MCP tools through the same service interfaces and shared utilities. Unified search also shares its presentation model and text formatter with MCP; the CLI supplies ANSI enablement and executable CLI action syntax. ## Experimental CLI commands @@ -232,19 +232,33 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. -**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. Callers follow the one rendered `search-status` action rather than repeating `search`; ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The first line says what was returned and whether work continues, followed by one readiness/trust block, result blocks, bounded alternatives or provenance, and at most one next action. Both surfaces therefore have the same wording, order, and wrapping. CLI enables ANSI emphasis when supported and substitutes directly executable CLI actions (`githits search-status`, `githits code read`, `githits docs read`, and `githits code grep`) for MCP tool-call syntax. Removing ANSI from CLI output leaves the same text contract apart from those supplied commands. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The order is outcome, target blocks with grouped readiness and usable alternatives, warnings/results, an optional session summary, and one positive next action. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and substitutes directly executable CLI actions (`githits search-status`, `githits code read`, `githits docs read`, and `githits code grep`) for MCP tool-call syntax. Removing ANSI from CLI output leaves the same text contract apart from those supplied commands. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. + +The representative CLI n8n active-empty output shape is: + +```text +Indexing - no results yet + +- npm:n8n -> 2.36.7 + Indexing: code, repository docs | Ready now: n8n.io docs (not searched; + pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, + master + +Search | 0/1 target ready +Next: githits search-status --wait 20 +``` **Highlighting.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, and optional evidence or alternatives are dim. Color never carries meaning and does not change wording or wrapping. -**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups its structured facts into `Waiting`, `Searched`, `Available but not searched`, and `Unavailable`; exact requested/fresh/served divergence appears once only when identities differ. Stale, provisional, capped, or mutable evidence is qualified once, while raw reason codes, indexing references, promoted duplicate warnings, and opaque evidence prose remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. +**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. Contributor-bearing rows omit redundant pair-level `resultCount`, pair-level `coverage`, and healthy resolution metadata from the compact JSON projection. Other source-status signals remain unchanged: ignored / incompatible filters and query features, terminal indexing notes, promoted freshness warnings, and ordered standalone-site recovery targets. Site suggestions come from `suggestedSiteTargets`; the exact `suggestedSiteTargetsTruncated` Boolean is retained whenever suggestions are present. They are advisory labels to retry explicitly, not aliases, and the client never selects or retries one automatically. -When pending or required work can change the disclosed snapshots, the result carries one backend-owned `evidenceNotice`. Human output renders it once. A known-active search with a `searchRef`, or a completed result whose evidence notice retains that reference, points to `search-status`; terminal `DEFERRED`, `TIMEOUT`, and `FAILED` responses never tell callers to repeat that terminal check. `DEFERRED` is not an empty-result or indexing claim: human output preserves the evidence, identifies the stopped session, and tells callers to start a later new search. Unrecognized statuses use the same conservative no-polling boundary without being labelled terminal or assigned other state-specific meaning. Without a reference, the notice explains that a later search retry may produce different hits or ordering. This notice is independent of `allowPartialResults`: pair-level partial results and partial/capped docpack coverage remain separate concepts. +When pending or required work can change the disclosed snapshots, the result carries one backend-owned `evidenceNotice`. JSON preserves it exactly; default human output uses concrete target-grouped stale, provisional, or coverage facts and does not render the notice as a generic slogan. A known-active search with a `searchRef`, or a completed result whose evidence notice retains that reference, points to `search-status`; reissuing the same search is valid and waits on the same underlying work. Terminal `DEFERRED`, `TIMEOUT`, and `FAILED` responses preserve disclosed evidence and direct callers to a later new search when appropriate. Unrecognized statuses retain their raw value without inferred lifecycle semantics. This notice is independent of `allowPartialResults`: pair-level partial results and partial/capped docpack coverage remain separate concepts. ### `githits search-status` @@ -255,7 +269,7 @@ githits search-status ref_abc123 --json Follow-up for a prior unified search. Use the `searchRef` only when `githits search` emits the explicit action, including when the initial request could not complete inside the wait window or a completed result carries an evidence notice. Before completion, `search-status` can return an atomic interim result when every runnable target/source pair is serveable; if the original request used `--allow-partial`, it can instead return a serveable subset while other pairs remain unavailable. -`PENDING`, `INDEXING`, and `SEARCHING` are active incomplete states and can be checked again with the same reference. `DEFERRED` is terminal even though JSON keeps `completed: false`: the session has stopped following lifecycle work, any stored `result` and progress remain usable, and callers must issue a later new `search` rather than poll the same reference. `TIMEOUT` and `FAILED` are also terminal. +`PENDING`, `INDEXING`, and `SEARCHING` are active incomplete states and can be checked again with the same reference. `DEFERRED` is terminal even though JSON keeps `completed: false`: the session has stopped following lifecycle work, any stored `result` and progress remain usable, and a later `search` starts a fresh session when needed. `TIMEOUT` and `FAILED` are also terminal. `search-status` deliberately does **not** reconstruct the original structured request echo. The backend status API exposes progress, final results, and the backend-normalized query string, but it does not expose the original target/filter/defaulting inputs. The JSON payload therefore contains only fields the follow-up endpoint can actually know: `{completed, searchRef?, progress?, result?}`. diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index b889444c..bb403dbc 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -236,35 +236,42 @@ test suite anchors the doc. ### Search output parity CLI human `search` / `search-status` and MCP `search` / `search_status` default -`text-v1` use one shared formatter that evolves in place. The text contract is -outcome-first: one outcome line, one concise readiness -and trust summary, result blocks, bounded alternatives or provenance, and one -action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct; active -no-snapshot output says that no result snapshot was returned, while active empty -output says that no results were returned yet. Active result counts use -`interim` when `partialResults` is false and `partial` when it is true. -Terminal and unknown statuses retain their exact status and never poll the same -reference. Site suggestions remain ordered advisory labels with explicit retry -guidance; they are never selected automatically. Parser/query and structured -constraint facts appear once below the outcome, while promoted lifecycle warning -prose and opaque evidence text stay out of default MCP text. - -The three anti-repeat directives are part of this text behavior: -`Do not repeat search.` for active polling, `Do not repeat this search unchanged.` -for an ordinary completed empty result, and `Do not repeat immediately.` for -evidence-limited or status-continuation actions. A rendered `searchRef` appears -only in the exact continuation action. MCP renders -`Next: search_status search_ref=... wait_timeout_ms=...`; CLI renders -`Next: githits search-status ... --wait ...`. Terminal and unknown responses -instead give an explicit transport-neutral no-poll instruction. - -CLI supplies ANSI enablement and CLI-native follow-up and pagination syntax to the -shared formatter; MCP supplies no color and MCP-native tool-call syntax. Hierarchy, -wrapping, fact selection, and wording apart from those syntax differences are -otherwise identical. Search-result -follow-ups likewise render as `githits code read` / `githits docs read` in CLI -and `code_read` / `docs_read` in MCP. ANSI-stripped CLI output is structurally -identical to no-color output. +`text-v1` use one shared formatter. The presentation model owns target groups, +readiness, trust limits, and action selection; the text renderer owns wording, +wrapping, hit anatomy, and ordering. Callers provide only ANSI enablement and +surface-native action syntax. The order is outcome headline, target blocks with +identity plus grouped readiness/usable alternatives, warnings and results, an +optional session summary, and one positive next action. + +`PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses +`Indexing - no results yet`; an active response without a snapshot uses +`Indexing - no result snapshot yet`, with corresponding lifecycle labels for +other active states. Active result counts use `interim` when `partialResults` is +false and `partial` when it is true. When session facts exist, the renderer may +emit one optional session row composed from available facts: `Search ` when +a reference exists, aggregate `/ target(s) ready` when progress +exists, and a lifecycle summary when a reference has no progress. The combined +form is `Search | / target(s) ready`; completed output +without session facts may omit it. A reference appears once in that row when +available and once in the follow-up action when the action carries it. Terminal +and unknown statuses retain their exact status. Site suggestions remain ordered +advisory labels and are never selected automatically. + +`evidenceNotice` remains exact in JSON and is not rendered as a generic +mutable-evidence slogan. Concrete stale, provisional, pending, and coverage +facts remain grouped under targets; parser/query and structured-constraint facts +appear once below the outcome. Promoted lifecycle warning prose, raw reason +codes, indexing references, and opaque evidence text stay out of default text. +Reissuing the same search is valid and waits on the same underlying work; text +does not emit negative repeat or poll policy directives. + +MCP renders `Next: search_status search_ref=... wait_timeout_ms=...`; CLI renders +`Next: githits search-status ... --wait ...`. The session row and continuation +action use the same reference when both are present; raw diagnostic fields are +never rendered. Search-result follow-ups likewise use +`code_read` / `docs_read` in MCP and `githits code read` / `githits docs read` in +CLI. ANSI-stripped CLI output is structurally identical to no-color MCP text +apart from those supplied command dialects. CLI `--json` output and MCP `format: "json"` output remain the structured parity boundary: every @@ -312,8 +319,9 @@ surface-native follow-up and pagination syntax plus ANSI differ. ### `PARITY-SHARED-TEXT-FORMATTER` -- Terminal rendering and MCP text rendering may share formatter code when - the output is useful to both humans and agents. +- Unified search terminal and MCP text rendering use one shared formatter; + other text surfaces may share formatter code when the output is useful to + both humans and agents. - Shared formatters must accept surface-specific hints so MCP never emits CLI-only instructions like `--verbose` or `--lifecycle all`. - Default MCP success output should be compact `text-v1`; programmatic @@ -354,9 +362,9 @@ When a new tool lands with both MCP and CLI surfaces: ## Non-goals -- **Forcing identical default prose.** CLI terminal output and MCP text - are related products, not identical products. Share formatters only - when the shape is useful on both surfaces and hints can be made +- **Forcing identical default prose outside unified search.** Unified search + deliberately shares wording, hierarchy, and wrapping; other CLI terminal + output and MCP text remain related products whose hints can be surface-native. - **Shared MCP description copy.** Each tool's description targets a different decision the agent is making. Copy is not reusable. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index bc6163aa..e19dde2e 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -78,8 +78,10 @@ Use the tools in these roles: package or repository, use `search`, `docs_*`, or `code_*` instead. - **Conditional search continuation:** Call `search_status` only when the preceding `search` response explicitly supplies both a `searchRef` and a - `search_status` action. The initial `search` call can complete; do not - repeat it to poll. Terminal or unrecognized statuses are not polled again. + `search_status` action. The initial `search` call can complete, and reissuing + the same search is valid while it waits on the same underlying work. A + terminal or unrecognized status ends that reference; start a later search + when a fresh session is needed. - **Package intelligence:** Use `pkg_info` for a latest-version health and adoption overview, `pkg_vulns` for CVEs/advisories and affected or fixed versions, `pkg_deps` for dependency graphs, `pkg_changelog` for release and @@ -100,7 +102,7 @@ Use the tools in these roles: | `search_language` | `query`, `format?` | Resolve a supported language name or alias for `get_example`; do not use it for source search. Defaults to one compact line per match; pass `format: "json"` for structured matches. | | `feedback` | `solution_id?`, `accepted`, `feedback_text?`, `tool_name?` | Submit feedback when a GitHits result or the overall experience was helpful, unhelpful, wrong, incomplete, slow, or confusing. Pass `solution_id` to rate an example or `tool_name` to identify a result. | | `search` | `query`, `target?`, `targets?`, `source?`, `category?`, `kind?`, `path_prefix?`, `file_intent?`, `public_only?`, `name?`, `language?`, `allow_partial_results?`, `limit?`, `offset?`, `wait_timeout_ms?`, `format?` | Discover relevant evidence in a known target before exact grep: docs, specs, code, symbols, tests, and examples ranked by relevance. Open-ended “how does”, “where is”, “find”, “locate”, or loosely phrased “grep the source” questions start here; omit `source` for broad discovery. A `search` call can return complete results directly; use `search_status` only when the response explicitly supplies a `searchRef` and action. | -| `search_status` | `search_ref`, `wait_timeout_ms?`, `format?` | Continue an explicit `search` reference only after that response supplies a `searchRef` and `search_status` action. Inspect progress or retrieve interim, partial, or final hits; terminal and unrecognized statuses are not polled again. | +| `search_status` | `search_ref`, `wait_timeout_ms?`, `format?` | Continue an explicit `search` reference only after that response supplies a `searchRef` and `search_status` action. Inspect progress or retrieve interim, partial, or final hits; terminal and unrecognized statuses end that reference, so use a later `search` for a fresh session. | | `docs_list` | `registry`, `package_name`, `version?`, `limit?`, `after?`, `format?` | List package documentation pages and hand off to `docs_read`; use `search` for topic discovery. Repo-backed entries include exact source metadata for `code_read` when available. | | `docs_read` | `page_id`, `start_line?`, `end_line?`, `format?` | Read a package documentation page by ID; use `docs_list` to browse and `search` to find topics. Text output returns 150 lines by default or up to 300 with an explicit range; repo-backed pages include exact `code_read` metadata. | | `pkg_info` | `registry`, `package_name`, `verbose?`, `format?` | Assess latest package health and adoption through license, downloads, and activity. Use `pkg_vulns` for advisory detail, `pkg_deps` for dependency graphs, `pkg_changelog` for release evidence, or `pkg_upgrade_review` for current-vs-target comparison. | @@ -140,9 +142,24 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. -**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` even when otherwise healthy. Repository contributors expose normalized `repositoryUrl`, full `commitSha`, freshness, and current-page `resultCount`; docpacks expose stable `siteKey`, canonical `siteUrl`, and selected published coverage. The JSON projection preserves meaningful zero/null values and every selected docpack coverage field, but omits duplicate pair-level count/coverage and incidental healthy resolution metadata. MCP text-v1 uses one outcome-first readiness block: fully current searched sources collapse to `Searched: site ...; repository docs ... @ `, while waiting, available-but-unsearched, unavailable, stale, provisional, partial, or capped sources retain the one concise state needed to interpret the result. Docpack labels use the canonical host/path from contributor `siteUrl`, then stable `siteKey`, then the retained target or generic site identity; returned hit URLs are never used to infer which site was searched. Target context is omitted for one target and retained only when multiple targets need disambiguation. A `SEARCHED` contributor with `PROVISIONAL` freshness explicitly says that a provisional index was searched while indexing continues. When any disclosed contributor was not searched, an empty headline scopes the claim to searched evidence. Multiple targets retain context labels when needed for disambiguation; the text does not synthesize contributor numbering. JSON remains the exact source for stable keys, canonical URLs, and all coverage fields. Partial/capped coverage is published evidence, not a progress or retry signal. - -`evidenceNotice` is carried once on initial and stored result envelopes. MCP text summarizes its presence once as `Evidence may change.` rather than copying opaque backend prose; JSON retains the exact notice. A `searchRef` is actionable only when rendered output supplies a `search_status` follow-up. Ordinary cases are known active progress (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result carrying an evidence notice. Terminal `DEFERRED` keeps `completed: false`, exact progress, and any stored result, but its `searchRef` no longer advances: callers use the disclosed evidence now and issue a new search later for a fresher snapshot. Terminal `DEFERRED`, `TIMEOUT`, and `FAILED` output never directs callers back to the same session. Session status is an open backend-owned string so adding an enum value does not invalidate the response. An unrecognized value is preserved in JSON and text with any disclosed evidence, but the client does not guess whether it is active or terminal and does not poll the same reference; it directs a later new search instead. Without a reference, the notice is the only retry-variability guidance. `search_status(includeResults: true)` uses the same result projection and formatter—contributors are never copied onto generic progress targets, and `allowPartialResults` retains its separate pair-omission meaning. +**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical +`contributors` and coverage in JSON. Text places the user-meaningful readiness +state under its target, using `Indexing`, `Searched`, `Ready now`, or +`Unavailable` details as applicable. Site identity, stale/provisional qualifiers, +and partial or capped coverage remain attached to that target; internal reason +codes and indexing references stay in JSON. Partial/capped coverage is published +evidence, not a progress or retry signal. + +`evidenceNotice` is carried once on initial and stored result envelopes. JSON +retains that exact backend-owned notice; default text does not render it or replace +it with a generic mutable-evidence slogan. Instead, concrete stale, provisional, +pending, and coverage facts remain grouped under the affected target. A +`searchRef` is actionable only when rendered output supplies a status follow-up. +Reissuing the same search is valid and waits on the same underlying work. Terminal +status and unknown-status handling remains conservative, while +`search_status(includeResults: true)` uses the same result projection and +formatter—contributors are never copied onto generic progress targets, and +`allowPartialResults` retains its separate pair-omission meaning. ### `pkg_info` response shape @@ -284,9 +301,51 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, and `pkg_upgrade_review` text mode reuse the shared no-color terminal formatters but inject MCP-native hints. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass verbose=true`, `body_lines=`, or `format="json"` when text omitted lines. Package tools keep JSON errors in all formats because agents can reliably branch on `{error, code, retryable, details?}`. -**Unified search outcome-first anatomy** (CLI human search/search-status and MCP `search` / `search_status` text-v1). One shared formatter owns both surfaces. The first nonblank line is one outcome: `Preparing`, `Indexing`, or `Searching` for active `PENDING`, `INDEXING`, or `SEARCHING`; a completed result/empty count; or the exact `DEFERRED`, `TIMEOUT`, `FAILED`, or unknown status. A no-snapshot active response says `no result snapshot returned yet`; an active empty snapshot says `no results returned yet`; active hits are labelled `interim` when `partialResults` is false and `partial` when it is true. The remainder is ordered as one readiness/trust summary, query or structured constraint warnings, result blocks, bounded alternatives/provenance and site suggestions, then one action. Progress-only responses show only derivable target readiness and alternatives; they never synthesize source or contributor facts. - -Active output keeps `Do not repeat search.` immediately before one exact action. MCP renders `Next: search_status search_ref="..." wait_timeout_ms=20000`; CLI renders `Next: githits search-status ... --wait 20`. Ordinary completed empty output keeps `Do not repeat this search unchanged.`; evidence-limited output uses `Do not repeat immediately.`; terminal and unknown statuses prohibit polling the stopped or unrecognized reference. Suggested site targets retain backend order and an omitted-candidates signal, but are advisory labels rather than automatic retries. Unified search text prints `searchRef` only inside its exact `Next:` action. +**Unified search outcome-first anatomy** (CLI human search/search-status and MCP +`search` / `search_status` text-v1). One shared presentation model owns target +groups and trust facts; one shared text renderer owns wording, wrapping, hit +anatomy, and ordering. Callers supply only ANSI enablement and surface-native +action syntax. The order is: + +1. outcome headline; +2. target blocks, each with identity plus grouped readiness and usable alternatives; +3. warnings and results; +4. an optional session summary; and +5. one positive next action, when applicable. + +Active lifecycle labels remain `Preparing`, `Indexing`, and `Searching` for +`PENDING`, `INDEXING`, and `SEARCHING`. The exact active empty wording is +`Indexing - no results yet`; when no snapshot exists it is +`Indexing - no result snapshot yet`, with the corresponding lifecycle label for +other active states. Active hits are labelled `interim` when `partialResults` is +false and `partial` when it is true. Progress-only responses show only derivable +target readiness and alternatives; they never synthesize source or contributor +facts. + +When session facts exist, text may include one optional session row composed from +the facts available: `Search ` when a reference exists, aggregate +`/ target(s) ready` when progress exists, and a lifecycle summary +when a reference has no progress. The combined form is +`Search | / target(s) ready`; completed output without +session facts may omit the row. A reference appears once in that row when +available and once in the follow-up action when the action carries it; raw +diagnostic fields are not rendered. MCP renders +`Next: search_status search_ref="..." wait_timeout_ms=20000`; CLI renders +`Next: githits search-status ... --wait 20`. Text emits no negative repeat or poll +policy directive: reissuing the same search is valid and waits on the same +underlying work. Suggested site targets retain backend order and an omitted- +candidates signal, but remain advisory labels rather than automatic retries. + +`evidenceNotice` stays exact in JSON and is not rendered in default text. The +renderer keeps concrete stale, provisional, pending, and capped-coverage facts +under their target, while raw reason codes, indexing references, promoted +duplicate warnings, and opaque evidence prose remain in JSON. Query/filter and +structured-constraint facts appear once below the outcome. Surface-native pivots +name `source="symbol"` / `code_grep` in MCP and `--source symbol` / +`githits code grep` in CLI. + +The representative CLI n8n example is maintained in +`docs/implementation/cli-commands.md` as the output source of truth. **Hit anatomy within unified search text-v1:** @@ -308,7 +367,19 @@ CLI uses `--offset N` / `--limit N`. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. -Completed empty search uses the model's applicable action: generic query pivots are suppressed for evidence-limited or unsearched sources, indexing/provisional evidence prefers waiting or an indexed alternative, standalone site searches expose only a shorter/broader site query, and filter removal or symbol/code-grep pivots appear only when applicable. Surface-native pivots name `source="symbol"` / `code_grep` in MCP and `--source symbol` / `githits code grep` in CLI. A completed result with both an evidence notice and `searchRef` emits one status continuation after the generic `Evidence may change.` trust statement. Terminal `DEFERRED`, `FAILED`, and `TIMEOUT` preserve disclosed evidence but prohibit further status calls; unknown statuses preserve the raw value and use the same conservative no-polling boundary. Promoted lifecycle/freshness warning prose and opaque evidence text remain in JSON but are not repeated in default text; parser/query and structured constraint facts appear once below the outcome. +Completed empty search uses the model's applicable action: generic query pivots are +suppressed for evidence-limited or unsearched sources, indexing/provisional +evidence prefers waiting or an indexed alternative, standalone site searches +expose only a shorter/broader site query, and filter removal or symbol/code-grep +pivots appear only when applicable. Surface-native pivots name +`source="symbol"` / `code_grep` in MCP and `--source symbol` / +`githits code grep` in CLI. A result with both an evidence notice and +`searchRef` emits one status continuation. Terminal `DEFERRED`, `FAILED`, and +`TIMEOUT` preserve disclosed evidence and their lifecycle state; unknown statuses +preserve the raw value without inferred semantics. Promoted lifecycle/freshness +warning prose, opaque evidence text, and the exact notice remain in JSON but are +not repeated in default text; parser/query and structured constraint facts appear +once below the outcome. **Listing anatomy** (`code_files` text-v1): diff --git a/docs/plans/search-output-ux.md b/docs/plans/search-output-ux.md deleted file mode 100644 index 414d5a5b..00000000 --- a/docs/plans/search-output-ux.md +++ /dev/null @@ -1,659 +0,0 @@ -# Plan: Search output information hierarchy - -## Status - -- Overall: **IN PROGRESS** -- Phase 1a: **COMPLETE** -- Phase 1b: **COMPLETE** (implemented in the same draft PR after the formatter - ownership correction) -- Phase 2: **PENDING** - -## Problem and expected outcome - -`githits search` and `githits search-status` currently expose the same indexing -state through warnings, progress fields, target-resolution prose, source notes, -documentation-contributor notes, and an evidence notice. The renderers append -those independent projections instead of deciding which facts the reader needs. -The result is repetitive, hard to scan, wider than the terminal, and especially -expensive in default MCP text output. - -When this work is complete: - -- the first line states what the command returned and whether indexing continues; -- each lifecycle, freshness, coverage, and continuation fact appears once; -- progress and source readiness are expressed in user terms instead of internal - reason codes and duplicated target identities; -- CLI color reinforces the information hierarchy without carrying meaning by - itself; -- CLI human output and MCP `text-v1` use the same semantic projection while - retaining surface-native actions; -- JSON remains the complete structured/debug representation; -- the same terminal hierarchy is applied to other high-information commands - where the follow-up audit proves equivalent problems. - -## Verified current state and evidence - -1. The reported `npm:n8n` response says indexing is active at least four ways: - three promoted warnings, the `Indexing/search still in progress` headline, - `status: indexing`, the target `state=pending`, source details, and the backend - evidence notice. -2. `formatProgressTarget()` repeats requested/fresh identities, `indexingRef`, - target-resolution notes, freshness reason, and indexed alternatives on one - unbounded line. In the supplied screenshot, that line exceeds the viewport. -3. `formatUnifiedSearchTerminal()` in `src/commands/search.ts` independently - concatenates warnings, progress, source status, documentation contributors, - and the evidence notice. `renderUnifiedSearchSuccess()` and - `renderUnifiedSearchStatusText()` build a second, different narrative for MCP. - No layer owns prioritization or cross-section deduplication. -4. Indexing and target-resolution conditions are promoted into top-level - `warnings[]` for structured callers. CLI and MCP text then render those warnings - alongside the structured progress/source facts that generated them. -5. The core service already receives `UnifiedSearchResult.partialResults`, but - `buildUnifiedSearchSuccessPayload()` and - `buildUnifiedSearchStatusResultPayload()` drop it. The CLI consequently labels - every incomplete result set `Partial results`, including atomic interim evidence - for which `partialResults` is false. -6. Search CLI status headlines and next actions are unstyled while result targets - and locations receive bold cyan emphasis and almost all provenance is dimmed. - The most important decision points therefore have less visual priority than - incidental identifiers. -7. The repository documents MCP `text-v1` as a public format, but the user has - explicitly decided that it is not a compatibility boundary for this redesign. - `text-v1` will be improved in place; no `text-v2` or legacy renderer is needed. -8. Phase 1a is implemented: the additive `partialResults` JSON field, one shared - presentation projection, outcome-first text rendering, tool/parity assertions, - and MCP smoke invariants are complete. The model's source-entry - boundary now uses required `searchTarget` for the searched package context; - the overloaded `contextTarget` is gone. Requested/fresh/served divergence is - retained only in progress and trust facts. -9. The original phase boundary was corrected after the user clarified that CLI is - the inspectable fidelity harness for MCP text and agents use both surfaces. - Phase 1b now routes CLI search/search-status through the same formatter, adds - ANSI and CLI-command inputs, and deletes the duplicated private CLI formatter. - -### Final integrated Phase 1 evidence - -- `bun test`: 3,380 tests passed, 0 failed, 10,869 expects across 184 files. -- `bun run typecheck`: clean; format and lint checked 437 files clean. -- Root and `packages/mcp` builds passed on merged `origin/main`. -- `bun run validate:packages` and `bun run validate:packages:mcp-publish` passed; - the publish dry-run was skipped because `@githits/mcp@0.11.1` is already - published. -- Source `bun run smoke:cli` and `bun run smoke:mcp` passed sequentially on the - final formatter state: 89 CLI steps and 46 MCP steps. An earlier parallel attempt - hit the backend rate limit in the unrelated `get_example` step; its evidence was - preserved and both suites passed after the rate window cleared. -- Targeted `unified-search-investigation` agent E2E succeeded with both Claude - and Codex; usefulness was helped/high confidence. The discovered symbol-label - bug was fixed. -- The final focused shared/status/tool/CLI cohort passed 149 tests with 0 failures - and 584 assertions before the smoke-contract unit cases were added. -- Production delta across the six runtime source files is 1,656 additions and 1,742 - deletions (net -86); the two source smoke harnesses add 81 lines and delete 10. - The single-formatter correction crossed the addition-only caution threshold but - deleted 818 lines from the CLI command and removed the obsolete shared helper - block instead of retaining two apparent implementations. The user explicitly - authorized the root-cause correction even if it grew this PR. -- `origin/main` at `7981d07` was integrated. One textual conflict in the permanent - tools document was resolved by retaining both the new code-navigation context-cap - guidance and this increment's unified-search contract. Integrated full-test, - build, package, and source-smoke verification passed. -- Built smoke suites were not required: smoke launch and CI product-validation - behavior did not change. - -## Scope - -### Phase 1a and 1b scope - -- CLI `search` and `search-status` human output for completed, active, terminal, - unknown, empty, interim, partial, stale, provisional, and capped-coverage states. -- MCP `search` and `search_status` default `text-v1` output for the same states. -- Shared response projection needed to distinguish actual partial evidence from - atomic interim evidence. -- Search-specific use of existing terminal colors and any smallest shared semantic - color helpers required to express the hierarchy cleanly. -- Search CLI/MCP documentation, smoke coverage, qualitative agent evaluation, and - release fragment. - -### Phase 2 scope - -- Other user-facing terminal formatters that the post-Phase-1 audit proves violate - the same hierarchy: primary outcome first, actionable state at full intensity, - muted detail only for optional provenance, and semantic severity colors. -- Permanent cross-command terminal-output guidance once the roles have been proven - by the search implementation. - -### Non-goals - -- Backend lifecycle, indexing, ranking, or evidence semantics. -- Changing search defaults, polling behavior, retry rules, or partial-result policy. -- Removing structured fields from JSON or hiding diagnostic data from `--json` / - `format: "json"`. -- A general rendering framework, theme engine, layout DSL, output mode, or new CLI - flag. -- Rewording unrelated command results during Phase 1. -- Using color as the only indication of state. -- Changing raw source/document content rendering; the existing terminal-text - sanitization plan owns that separate trust boundary. -- Changing search error-envelope shape or error semantics. Existing CLI and MCP - error rendering remains unchanged in Phase 1. - -## Target architecture - -### Ownership - -The shared search presentation layer owns the meaning and priority of response -facts. The response builder continues to own lossless structured projection. One -shared text formatter owns hierarchy, wording, wrapping, hit anatomy, and semantic -color roles. CLI and MCP callers supply only ANSI enablement and surface-native -action syntax. - -```text -Core UnifiedSearchOutcome - | - v -shared JSON payload builder ----> CLI --json / MCP format=json - | - v -shared search presentation model - | - v -shared search text formatter - | | - v v -CLI: ANSI + CLI actions MCP: no ANSI + MCP actions -``` - -This corrects both ownership problems: neither surface rediscovers semantic state -from warning strings, and wording/layout cannot drift between duplicated renderers. -CLI output remains a directly inspectable proxy for MCP token and output quality. - -### Presentation model - -Add one pure shared projection that derives four independent dimensions from the -typed payload: - -- **availability**: no snapshot, empty snapshot, interim results, partial results, - or final results; -- **lifecycle**: the exact active status (`PENDING`, `INDEXING`, or `SEARCHING`), - completed, the exact terminal status (`DEFERRED`, `TIMEOUT`, or `FAILED`), or - an unrecognized raw status; -- **trust limits**: older snapshot, provisional index, pending/unsearched source, - incomplete/capped coverage, ignored or incompatible query constraints; -- **action**: poll the current reference, start a later search, change the query or - source, use an indexed alternative, or none. - -The projection must consume structured fields. It must not parse promoted warning -prose. Promoted warnings remain available in JSON, while text renderers show only -query/filter/source problems not already represented by the lifecycle and trust -dimensions. - -The model contains display facts, not finished sentences or ANSI codes. It retains -the exact target/source identities and continuation reference needed by the formatter, -but omits internal-only `freshnessReason`, `requestedRefKind`, and `indexingRef` from -default text unless one becomes a verified user action. Those values remain in JSON. - -The source-entry boundary is explicit: `searchTarget` names the searched -package/target context, while `target` remains the served or contributor identity. -The former overloaded `contextTarget` is not used. Requested/fresh/served divergence -lives in progress and trust facts, so it cannot accidentally rename a result based -on contributor or docpack identity. - -### Structured contract correction - -Preserve the backend's actual `partialResults` Boolean on initial search payloads -and stored status results. Both renderers use it to distinguish: - -- `N interim results returned` when an active response contains an atomic - serveable snapshot (`partialResults: false`); and -- `N partial results returned` only when the backend says the snapshot is a subset - (`partialResults: true`). - -This is an additive JSON field. No GraphQL/API selection change is needed because -both search queries already select `partialResults` and core already validates it. - -### Information hierarchy - -Every human/agent text response follows this order: - -1. **Outcome headline** — what was returned and whether work continues. -2. **Progress/trust summary** — only facts needed to interpret that outcome. -3. **Results**, when any were returned. -4. **Bounded secondary provenance/alternatives**, only when actionable or needed to - qualify the evidence. -5. **One next action**, when applicable. - -Rules: - -- Active output starts with the exact work state, never with warnings: - `Preparing`, `Indexing`, or `Searching` for `PENDING`, `INDEXING`, or `SEARCHING`. -- Do not say `No hits` when no result snapshot was searched; say no results were - returned yet. -- Do not print `status: indexing` after an indexing headline. -- Print `searchRef` only inside the exact next action in human and MCP text. -- Do not print `indexingRef` in default text. -- Collapse requested/fresh/served identities to the one identity that changes the - user's interpretation. Explain divergence once in plain language. -- Group readiness by user-facing evidence source (`code`, `repository docs`, site - docs), not by raw source-status rows. -- State `available but not searched` distinctly from `waiting` and `searched`. -- Treat `evidenceNotice` presence as one concise text-level trust signal that the - disclosed evidence or ordering may change. Do not parse or reproduce its opaque - prose in default text. Preserve the verbatim notice in JSON. -- Bound alternatives in text by category: show at most three versions and three - refs, then `+N more`; JSON remains complete. -- Keep each status/provenance line bounded and independently wrappable. Never join - the complete target diagnostic record with ` | `. -- Query/filter incompatibilities remain visible once, below the outcome headline. -- Active responses retain `Do not repeat search.` before the exact status action. - Completed empty responses retain `Do not repeat this search unchanged.`, and - evidence-limited responses retain `Do not repeat immediately.` Terminal responses - retain a transport-neutral prohibition on polling a stopped reference. These - guardrails remain on both surfaces because agents can invoke either one. - -The action dimension also preserves the existing empty-result pivot rules: - -1. evidence-limited or unsearched-source results suppress generic query pivots; -2. indexing/provisional results suggest waiting or an indexed alternative, not query - rewriting; -3. standalone site searches suggest only a shorter/broader site query and never - another source or `code_grep`; -4. removing filters or switching to symbol search is suggested only when those pivots - apply to the actual request. - -### Implemented CLI shape for the reported active empty snapshot - -```text -Indexing npm:n8n@2.36.7 - no results returned yet -Ready: 0/1 targets -Target: requested npm:n8n; fresh npm:n8n@2.36.7 -Waiting: code, repository docs -Available but not searched: n8n.io docs (1,480 pages; capped) -Evidence may change. -Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2 more; refs HEAD, -master -Do not repeat search. -Next: githits search-status fabUr1S3MEVeSgD93pMoSQ --wait 20 -``` - -The supplied text proves this response contains an empty result snapshot with -`sourceStatus` and documentation contributors: contributor identity, readiness, and -page counts cannot come from progress alone. The regression fixture will encode the -disclosed structured facts from the supplied output; it will not depend on reproducing -the transient production indexing state with a fresh network call. The regression -now passes through the same formatter as MCP; only the final command dialect and -ANSI option differ. - -A true progress-only CLI response has no `sourceStatus` or documentation contributors -and therefore renders only derivable facts: - -```text -Indexing npm:n8n@2.36.7 - no result snapshot returned yet -Ready: 0/1 targets -Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2; refs HEAD, master - -Next: githits search-status --wait 20 -``` - -It must not synthesize per-source waiting state, site identity, or page coverage. - -### Other response shapes - -```text -Indexing continues - 4 interim results returned -Ready: 1/2 targets - - - -Next: githits search-status --wait 20 -``` - -```text -Indexing continues - 4 partial results returned -Ready: 1/2 targets - - - -Next: githits search-status --wait 20 -``` - -```text -10 results from npm:n8n@2.26.9 -Latest npm:n8n@2.36.7 is still indexing; these results use the older snapshot. - - -``` - -Completed current results retain the existing result blocks but use a concise count -headline and at most one source-provenance line before the hits. Completed empty -results state which evidence was actually searched before suggesting one applicable -pivot. Terminal and unknown states preserve disclosed evidence without inventing -indexing, completion, or absence claims. - -### Color semantics - -Phase 1 uses a small semantic mapping: - -- active indexing / degraded-but-usable headline: bold yellow; -- failed terminal headline: bold red; -- completed result count and primary result identity: bold neutral; -- exact next command: cyan or bold cyan; -- backend match spans: existing bold yellow; -- optional provenance, bounded-alternative remainder, and secondary metadata: dim; -- warnings that require a user decision: full-intensity yellow, never dim. - -No-color output keeps identical wording, order, spacing, labels, and glyph-independent -meaning. Do not color entire status paragraphs or whole result locations merely because -they are identifiers. - -## Assumptions and unknowns - -### Overall assumptions - -1. Phases 1a and 1b ship in one PR. The earlier merge boundary was removed after the - user clarified that CLI must be the directly inspectable fidelity harness for MCP - text and both humans and agents invoke it. -2. `text-v1` may change in place, per the user's explicit decision on 2026-08-26. -3. JSON is the correct place for full diagnostic identities, reason codes, - indexing references, and unbounded alternatives. -4. Existing lifecycle statuses and conservative handling of unknown statuses remain - authoritative. -5. The current backend fields are sufficient for Phase 1; no new service call or - backend change is required. -6. The implemented source-entry boundary uses `searchTarget` for searched package - context and keeps requested/fresh/served divergence in progress/trust facts. - -### Overall unknowns - -- The exact Phase 2 command cohort. Resolve at the Phase 1 boundary by comparing - representative no-color/color output from every formatter that uses shared color - helpers against the proven hierarchy. This does not block Phase 1. -- Whether permanent terminal-output guidance belongs in a new focused implementation - document or an existing CLI document. Resolve during Phase 2 reorientation based on - the size of the proven cross-command contract. - -### Open product decisions - -None for Phases 1a and 1b. - -### Resolved product decisions - -- Improve MCP `text-v1` in place; do not add a versioned compatibility branch. -- Default human/MCP text may summarize opaque `evidenceNotice` prose as one generic - trust limitation. Exact backend prose remains available in JSON. This deliberately - makes default text lossy to remove the reported token-heavy boilerplate while still - stating that returned evidence or ordering may change. - -## Cross-cutting considerations - -### Compatibility and migration - -- CLI human text intentionally changes; `--json` remains the automation boundary. -- MCP `text-v1` intentionally changes in place by user decision. Tool schemas and - default format names remain unchanged. -- `partialResults` is added to structured initial and status-result JSON. Existing - fields retain their meaning. -- Search and search-status must remain behaviorally aligned for the same stored - result and lifecycle state. - -### Security - -This work must not copy or expose credentials. It does not add network calls. New -formatting must follow the separate terminal-text sanitization plan when that shared -helper becomes available; Phase 1 does not absorb the broader sanitization effort. - -### Performance - -The presentation projection is a linear pass over already-bounded targets, source -statuses, contributors, warnings, and results. No benchmark is required because this -is not an optimization and adds no I/O, cache, or repeated search. Avoid sorting large -backend collections; preserve backend order and cap only display projection. - -### Release boundary - -Phase 1 changes shared MCP/CLI text and adds `partialResults` to both MCP JSON and -root CLI `--json`. Its single cohesive fragment uses `githits: patch` and -`@githits/mcp: patch`. Patch is appropriate because this corrects -misleading/duplicated output within the current minor and adds one structured truth -field without removing or redefining existing fields. The 0.11.0 precedent is not -comparable: it added public `quick_start`/configuration APIs and a deprecation path; -the closer 0.6.4 agent-facing search/recovery change was a patch. Retain patch/patch. - -The formatter ownership correction is part of that same user-visible search-output -fix, so it does not add a second fragment. Do not edit `CHANGELOG.md` or package -versions outside release preparation. - -Phase 2 will add its own fragment. Expected impact is `githits: patch` and -`@githits/mcp: none` if it changes only CLI ANSI styling; re-evaluate if shared MCP -text changes. - -### Documentation - -- Update `docs/implementation/cli-commands.md` with the outcome-first search family - contract and concise examples. -- Update `docs/implementation/tools.md` with the revised in-place `text-v1` anatomy. -- Update `docs/implementation/mcp-cli-parity.md` to state that search shares semantic - projection while rendering surface-native commands. -- Update MCP tool/instruction copy only where it describes the old output anatomy. -- Phase 2 records durable cross-command semantic color rules after they are proven. - -## Phase map - -### Phase 1a — Shared semantics and MCP text become outcome-first - -- Status: **COMPLETE** -- Delivered: structured search payloads preserve actual partialness, one pure model - owns lifecycle/availability/trust/action decisions, and MCP `text-v1` clearly - states what was returned without duplicate lifecycle prose. Source provenance - keeps explicit searched-target context separate from served/contributor identity. -- Verification: see `Final integrated Phase 1 evidence` above. No major Phase 1a item is - deferred and no Phase 1a TODO remains. - -### Phase 1b — CLI search output gains the same hierarchy and useful color - -- Status: **COMPLETE** -- Delivered: CLI search/search-status invoke the same shared formatter as MCP; - callers vary only ANSI and command dialect. The reported active-empty case is - concise, CLI actions are directly executable, and 780 lines of duplicated private - CLI formatting were deleted. -- Verification: targeted CLI/shared/status tests, ANSI-stripped parity, full tests, - builds, package validation, source smoke, and retained Opus review all pass. - -### Phase 2 — Proven terminal hierarchy becomes consistent across commands - -- Status: **PENDING** -- Expected outcome: other high-information CLI commands with verified hierarchy or - color-role problems use the same semantic roles without unrelated copy redesign. -- Assumptions: Phase 1 establishes usable roles and test patterns; Phase 2 remains a - separate increment to contain review scope. -- Unknowns or product decisions: exact formatter cohort and durable documentation - location, resolved at phase-boundary reorientation. -- Dependencies: Phase 1 merged and reorientation against current `origin/main`. -- Acceptance criteria: - - every migrated command has an outcome-first first screenful; - - warnings/actions are not dimmed and colors follow the documented roles; - - no-color output conveys the same state and action; - - unchanged commands are explicitly shown not to violate the proven rules; - - no general theme/rendering infrastructure is introduced. - -## Phase 1a and 1b detailed implementation plan - -### Expected outcome - -Phase 1 delivers correct structured truth plus one shared compact formatter for CLI -and MCP. Interim, actual partial, completed, stale/provisional, terminal, and unknown -cases use the same hierarchy. The surfaces cannot independently reintroduce duplicate -lifecycle prose because both presentation decisions and final text layout have one -owner. - -### Likely affected components - -- `packages/mcp/src/shared/unified-search-response.ts` -- new `packages/mcp/src/shared/unified-search-presentation.ts` -- `packages/mcp/src/shared/unified-search-text.ts` -- `packages/mcp/src/shared/unified-search-status-text.ts` -- `packages/mcp/src/shared/target-resolution.ts` only if display facts must be split - from current prose helpers -- `packages/mcp/src/internal.ts` -- `src/commands/search.ts` -- `packages/mcp/src/shared/follow-up-command-text.ts` for surface-native commands -- existing shared color primitives; no CLI-only renderer or new framework -- colocated response, presentation, renderer, tool, command, parity, smoke, and color - tests -- implementation documentation and the phase-specific changes fragments - -### Ordered implementation - -#### Phase 1a — structured truth, presentation model, and MCP text (complete; do not repeat) - -The numbered execution list is superseded by the completed implementation. Phase 1a -added the additive `partialResults` field, the pure presentation projection, and the -MCP `text-v1` search/status renderers; migrated tool/parity tests and MCP smoke -invariants; updated permanent docs and the patch/patch release fragment; and passed -the final verification and agent evaluation recorded above. The final corrective -boundary uses `searchTarget` for searched package context, keeps `target` as served or -contributor identity, and retains requested/fresh/served divergence only in progress -and trust facts. No further Phase 1a execution is pending. - -#### Phase 1b — shared CLI/MCP formatter and color hierarchy (complete) - -The CLI now passes its payload to `renderUnifiedSearchSuccess()` or -`renderUnifiedSearchStatusText()` with `useColors` and `actionSyntax: "cli"`. -MCP uses the same functions with no color and MCP action syntax. Shared tests prove -the same layout with substituted continuation commands; CLI tests prove initial and -status equality for the n8n regression plus ANSI-stripped text parity. The private -CLI search/status formatter and its duplicate hit/provenance helpers were deleted. - -### Edge cases and failure behavior - -- Missing `progress`: state what is known without inventing indexing details; an active - reference can still supply the exact next action. -- Progress without a result/source status: render target readiness and target-level - alternatives only; never synthesize evidence sources, contributor readiness, site - identity, or page counts. -- Unknown lifecycle status: print the raw status once, preserve evidence, do not label - it active/terminal, and do not poll the same reference. -- `DEFERRED`, `FAILED`, and `TIMEOUT`: never emit search-status polling guidance. -- Incomplete response with results and `partialResults: false`: call results interim, - not partial. -- Incomplete response with `partialResults: true`: explicitly state that requested - evidence is missing. -- Completed response with evidence notice/search reference: state results are returned - and may change, then emit one continuation action. -- Available-but-unsearched docs contributor: never describe it as searched or pending. -- Capped/partial docs coverage: disclose evidence limits without calling them indexing - progress or suggesting a wait. -- Stale/provisional/fallback results: identify the served evidence once and keep - follow-up locators pinned to it. -- Multiple targets: retain labels only where needed for disambiguation; do not repeat - the same requested/fresh identity per source. -- Site suggestions: preserve backend order, truncation signal, and explicit retry - labels without automatic selection. -- Long alternatives/targets: cap display and wrap by terminal cells; never truncate the - exact next command or result follow-up. -- Color-disabled/non-TTY output: identical words and layout, no ANSI. - -### Phase 1a and 1b acceptance criteria - -Implementation criteria below are verified by targeted and integrated tests, both -source smoke suites, and the completed follow-up review. - -- The n8n-shaped active empty-snapshot CLI fixture starts with indexing, contains one - readiness summary, distinguishes waiting from available-but-unsearched evidence, - omits raw reason codes and `indexingRef`, bounds alternatives, and ends with one - exact status command. -- A progress-only fixture emits only the lifecycle headline, target readiness, - target-level alternatives when present, and one next action; it does not invent - source or contributor details. -- No lifecycle/freshness fact appears in more than one human/MCP text section. -- `PENDING`, `INDEXING`, and `SEARCHING` produce distinct preparing, indexing, and - searching headlines; terminal and unrecognized raw statuses likewise remain - distinct and are never collapsed before rendering. -- The model classifies every result-bearing response as final, interim, or partial from - lifecycle plus `partialResults`; rendered copy never calls an interim snapshot final - or an atomic interim snapshot partial. -- No-snapshot states never claim zero hits; completed empty snapshots never imply - sources were searched when they were not. -- CLI and MCP invoke the same formatter and differ only in ANSI enablement and - surface-native command syntax. -- Shared-renderer parity tests substitute the surface action and assert the remaining - text is identical; CLI tests assert search/status equality for the n8n fixture. -- `--json` and `format: "json"` remain equal and add the exact `partialResults` Boolean; - full diagnostic fields and alternative lists remain available. -- Active states have one continuation action; terminal/unknown states obey existing - conservative no-polling rules. -- Shared text retains the three documented anti-repeat directives and all four conditional - empty-result pivot-suppression rules. -- CLI status hierarchy remains readable with colors disabled, and ANSI-stripped color - output is identical to no-color output. -- Explicit tests cover all listed states and the existing targeted baseline remains - green after updated expectations. -- Required unit, parity, smoke, build, package-validation, and qualitative agent checks - pass or any environment-only limitation is reported with exact evidence. -- CLI smoke structurally verifies the outcome-first headline, absence of duplicate - `status:` prose, and single action-contained `searchRef` when continuation exists. -- Permanent docs and the cohesive Phase 1 changes fragment match implemented - behavior. - -### Verification - -Run at minimum: - -```text -bun test -bun test -bun run typecheck -bun run format:check -bun run lint -bun run build -(cd packages/mcp && bun run build) -bun run validate:packages -bun run validate:packages:mcp-publish -bun run smoke:cli -bun run smoke:mcp -``` - -Run targeted `bun run agent:e2e` search lifecycle workloads. Use both Claude and Codex -when practical because default agent text and continuation guidance change broadly. -Built smoke suites are required only if smoke launch behavior or built-product CI -validation changes; otherwise source smoke plus both package builds/validators are the -proportionate gates. - -## Phase-boundary reorientation - -After Phase 1 merges, run `$next-steps` before detailing Phase 2. Record observed color/no-color -output, accepted/rejected UX rules, test/eval evidence, and any command-specific -exceptions. Then inventory the remaining formatter call sites using those proven rules, -select the smallest coherent command cohort, and add exact files and test tactics for -Phase 2. Do not continue from a stale Phase 2 outline if the search roles did not -generalize cleanly. - -## Completion and plan cleanup - -The overall effort is complete when Phases 1a, 1b, and 2 meet their acceptance criteria, -permanent implementation documentation owns the resulting search and terminal-output -contracts, all required release fragments exist, and no temporary design decision -remains only in this plan. Then delete this plan. If Phase 2 is explicitly removed from -scope, transfer the verified Phase 1a/1b contract to permanent docs and delete the plan -after Phase 1b rather than retaining a stale future-work artifact. - -## Review record - -- Internal technical review: findings covering exact active statuses, explicit - evidence-notice lossiness, and duplicate coverage were accepted and fixed. -- Luna preflight findings on bounded summary wrapping and `hasMore` ownership were - fixed. The initial Opus loop findings were also fixed; that loop exposed the - overloaded source-target identity later corrected by the explicit `searchTarget` - boundary. -- The user selected that root-cause boundary correction. Retained follow-up Opus - rounds found and closed the CLI pagination dialect leak, obsolete formatter helper - block, missing smoke-predicate unit coverage, and stale parity wording. The final - round was clean. Its two non-blocking observations were also fixed inline: the - smoke predicate now accepts legitimate completed-empty and terminal actions, and - the parity wording states the exact syntax exceptions. -- Repository policy prevented a second internal `code_reviewer`: this session had - already used its one allowed reviewer for the technical plan. -- Rejected remedy: do not issue a fresh live search to capture transient JSON. The - original indexing state may no longer exist; code inspection proves contributor - details require a result/source-status snapshot, and the regression fixture can - encode every fact disclosed in the supplied output without a network call. diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 394ce7ba..78c88e7c 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { projectUnifiedSearchPresentation } from "./unified-search-presentation.js"; +import { + projectUnifiedSearchPresentation, + targetDisplayFamilyKey, +} from "./unified-search-presentation.js"; import type { UnifiedSearchCompletedPayload, UnifiedSearchIncompletePayload, @@ -672,6 +675,39 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); + it.each([ + ["resolvedRequested", "npm:express@5.2.1"], + ["served", "npm:express@5.1.0"], + ] as const)( + "anchors %s-only progress alternatives to the target group", + (identityKey, identity) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + targets: [ + { + [identityKey]: identity, + availableVersions: [{ version: "4.18.2", ref: "v4.18.2" }], + }, + ], + }, + }), + ); + + expect(presentation.alternatives).toEqual([ + expect.objectContaining({ target: identity }), + ]); + expect(presentation.targetGroups).toHaveLength(1); + expect(presentation.targetGroups[0]?.alternatives).toEqual( + expect.objectContaining({ target: identity }), + ); + }, + ); + it("retains progress target identities without diagnostics or alternatives", () => { const presentation = projectUnifiedSearchPresentation( incomplete({ @@ -723,6 +759,26 @@ describe("projectUnifiedSearchPresentation", () => { incomplete({ partialResults: false, results: [], + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 8200, + targets: [ + { + requested: "npm:n8n", + resolvedRequested: "npm:n8n@2.36.7", + freshness: "INDEXING", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + { version: "2.22.6", ref: "v2.22.6" }, + ], + availableRefs: [{ ref: "HEAD" }, { ref: "master" }], + }, + ], + }, sourceStatus: [ source({ source: "code", @@ -776,6 +832,99 @@ describe("projectUnifiedSearchPresentation", () => { kind: "active", status: "INDEXING", }); + expect(presentation.targetGroups).toEqual([ + { + identity: { + requested: "npm:n8n", + fresh: "npm:n8n@2.36.7", + freshness: "INDEXING", + }, + sources: [ + { + kind: "code", + entries: [ + { + state: "waiting", + target: "npm:n8n@2.36.7", + searchTarget: "npm:n8n@2.36.7", + resultCount: 0, + }, + ], + }, + { + kind: "site_docs", + entries: [ + { + state: "available_not_searched", + target: "https://n8n.io", + searchTarget: "npm:n8n@2.36.7", + resultCount: 0, + siteKey: "n8n.io", + siteUrl: "https://n8n.io", + }, + ], + }, + { + kind: "repository_docs", + entries: [ + { + state: "waiting", + target: "https://github.com/n8n-io/n8n", + searchTarget: "npm:n8n@2.36.7", + resultCount: 0, + repositoryUrl: "https://github.com/n8n-io/n8n", + }, + ], + }, + ], + alternatives: { + target: "npm:n8n", + versions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + ], + versionsRemaining: 1, + refs: [{ ref: "HEAD" }, { ref: "master" }], + refsRemaining: 0, + suggestedRefs: [], + suggestedRefsRemaining: 0, + }, + siteSuggestions: [], + trustLimits: [ + { + kind: "source", + source: "code", + state: "waiting", + target: "npm:n8n@2.36.7", + }, + { + kind: "source", + source: "site_docs", + state: "available_not_searched", + target: "https://n8n.io", + }, + { + kind: "source", + source: "repository_docs", + state: "waiting", + target: "https://github.com/n8n-io/n8n", + }, + { + kind: "coverage", + source: "site_docs", + state: "capped", + target: "https://n8n.io", + pagesCrawled: 1480, + frontierRemaining: undefined, + estimatedTotalPages: undefined, + }, + ], + }, + ]); + expect( + presentation.targetGroups.flatMap((group) => group.trustLimits), + ).not.toContainEqual({ kind: "mutable_evidence" }); expect(presentation.sources).toEqual([ { kind: "code", @@ -841,6 +990,171 @@ describe("projectUnifiedSearchPresentation", () => { }); }); + it("groups resolved and served target labels without mutating flat identities", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 2, + elapsedMs: 200, + targets: [ + { + requested: "npm:express latest", + resolvedRequested: "npm:express@5.2.1", + freshness: "INDEXING", + availableVersions: [{ version: "5.0.0", ref: "v5.0.0" }], + }, + { + requested: "npm:koa@3.0.0", + resolvedRequested: "npm:koa@3.0.0", + freshness: "INDEXING", + }, + ], + }, + sourceStatus: [ + source({ + targetLabel: "npm:express@5.2.1", + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + codeIndexState: "STALE", + }), + source({ + targetLabel: "npm:koa@3.0.0", + codeIndexState: "CURRENT", + }), + ], + }), + ); + + expect(targetDisplayFamilyKey("npm:express")).toBe( + targetDisplayFamilyKey("npm:express latest"), + ); + expect(targetDisplayFamilyKey("npm:express latest")).toBe( + targetDisplayFamilyKey("npm:express@5.2.1"), + ); + expect(targetDisplayFamilyKey("github:expressjs/express#main")).toBe( + targetDisplayFamilyKey("github:expressjs/express#refs/heads/main"), + ); + expect( + targetDisplayFamilyKey("github:expressjs/express@refs/heads/main"), + ).toBe(targetDisplayFamilyKey("github:expressjs/express")); + + expect(presentation.targetGroups).toHaveLength(2); + const expressGroup = presentation.targetGroups.find( + (group) => group.identity.requested === "npm:express latest", + ); + expect(expressGroup).toEqual( + expect.objectContaining({ + identity: expect.objectContaining({ + requested: "npm:express latest", + fresh: "npm:express@5.2.1", + served: "npm:express@5.1.0", + }), + alternatives: expect.objectContaining({ + target: "npm:express latest", + }), + }), + ); + expect(expressGroup?.sources).toEqual([ + { + kind: "code", + entries: [ + expect.objectContaining({ + target: "npm:express@5.1.0", + searchTarget: "npm:express@5.1.0", + }), + ], + }, + ]); + expect(expressGroup?.trustLimits).toEqual([ + expect.objectContaining({ + kind: "stale", + servedTarget: "npm:express@5.1.0", + }), + ]); + expect(presentation.targets).toEqual([ + { + requested: "npm:express latest", + fresh: "npm:express@5.2.1", + freshness: "INDEXING", + }, + { + requested: "npm:koa@3.0.0", + fresh: "npm:koa@3.0.0", + freshness: "INDEXING", + }, + ]); + }); + + it("keeps explicit package versions in separate target groups", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 2, + elapsedMs: 200, + targets: [ + { + requested: "npm:express@4.18.2", + resolvedRequested: "npm:express@4.18.2", + availableVersions: [{ version: "4.18.1", ref: "v4.18.1" }], + }, + { + requested: "npm:express@5.2.1", + resolvedRequested: "npm:express@5.2.1", + availableVersions: [{ version: "5.2.0", ref: "v5.2.0" }], + }, + ], + }, + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + targetResolution: { + availableVersions: [], + availableRefs: [], + }, + }), + source({ + targetLabel: "npm:express@5.2.1", + targetResolution: { + availableVersions: [], + availableRefs: [], + }, + }), + ], + }), + ); + + expect(presentation.targetGroups).toHaveLength(2); + expect( + presentation.targetGroups.map((group) => ({ + target: group.identity.requested, + sourceTargets: group.sources.flatMap((sourceGroup) => + sourceGroup.entries.map((entry) => entry.target), + ), + alternatives: group.alternatives?.versions.map( + (alternative) => alternative.version, + ), + })), + ).toEqual([ + { + target: "npm:express@4.18.2", + sourceTargets: ["npm:express@4.18.2"], + alternatives: ["4.18.1"], + }, + { + target: "npm:express@5.2.1", + sourceTargets: ["npm:express@5.2.1"], + alternatives: ["5.2.0"], + }, + ]); + }); + it("classifies stale, fallback, and provisional trust limits", () => { const presentation = projectUnifiedSearchPresentation( completed({ diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index aa330306..f5065d21 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -114,6 +114,14 @@ export interface UnifiedSearchSiteSuggestionFacts { truncated: boolean; } +export interface UnifiedSearchTargetGroup { + identity: UnifiedSearchTargetPresentation; + sources: UnifiedSearchSourceGroup[]; + alternatives?: UnifiedSearchAlternativeFacts; + siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; + trustLimits: UnifiedSearchTrustLimit[]; +} + export type UnifiedSearchConstraintKind = | "ignored_filter" | "incompatible_filter" @@ -191,6 +199,7 @@ export interface UnifiedSearchPresentation { searchRef?: string; progress?: UnifiedSearchProgressPresentation; targets: UnifiedSearchTargetPresentation[]; + targetGroups: UnifiedSearchTargetGroup[]; hasMore: boolean; sources: UnifiedSearchSourceGroup[]; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; @@ -211,6 +220,7 @@ interface SnapshotFacts { interface CandidateSet { target?: string; + aliases: string[]; versions: UnifiedSearchAlternative[]; refs: UnifiedSearchAlternative[]; suggestedRefs: UnifiedSearchAlternative[]; @@ -234,6 +244,14 @@ export function projectUnifiedSearchPresentation( const warnings = projectWarnings(query, sourceStatus); const alternatives = projectAlternatives(progress, sourceStatus); const searchRef = "searchRef" in payload ? payload.searchRef : undefined; + const targets = projectTargets(progress); + const targetGroups = projectTargetGroups({ + targets, + sources, + alternatives, + siteSuggestions, + trustLimits, + }); return { availability, @@ -241,7 +259,8 @@ export function projectUnifiedSearchPresentation( query, searchRef, progress: projectProgress(progress), - targets: projectTargets(progress), + targets, + targetGroups, hasMore: snapshot?.hasMore ?? false, sources, siteSuggestions, @@ -651,7 +670,12 @@ function projectAlternatives( ): UnifiedSearchAlternativeFacts[] { const candidates: CandidateSet[] = [ ...(progress?.targets ?? []).map((target) => ({ - target: target.requested, + target: target.requested ?? target.resolvedRequested ?? target.served, + aliases: uniqueAliases([ + target.requested, + target.resolvedRequested, + target.served, + ]), versions: target.targetResolution?.availableVersions ?? target.availableVersions ?? @@ -667,6 +691,13 @@ function projectAlternatives( ? [ { target: sourceTarget(entry), + aliases: uniqueAliases([ + sourceTarget(entry), + entry.targetLabel, + entry.requestedTarget, + entry.freshTarget, + entry.servedTarget, + ]), versions: resolution.availableVersions, refs: resolution.availableRefs, suggestedRefs: resolution.suggestedRefs ?? [], @@ -692,22 +723,189 @@ function projectAlternatives( })); } +interface TargetGroupInput { + targets: UnifiedSearchTargetPresentation[]; + sources: UnifiedSearchSourceGroup[]; + alternatives: UnifiedSearchAlternativeFacts[]; + siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; + trustLimits: UnifiedSearchTrustLimit[]; +} + +function projectTargetGroups( + input: TargetGroupInput, +): UnifiedSearchTargetGroup[] { + const groups: UnifiedSearchTargetGroup[] = []; + for (const identity of input.targets) { + const existing = groups.find((group) => + targetIdentityValues(identity).some((target) => + targetIdentityValues(group.identity).includes(target), + ), + ); + if (existing) { + existing.identity.requested ??= identity.requested; + existing.identity.fresh ??= identity.fresh; + existing.identity.served ??= identity.served; + existing.identity.freshness ??= identity.freshness; + continue; + } + groups.push({ + identity: { ...identity }, + sources: [], + siteSuggestions: [], + trustLimits: [], + }); + } + + const findOrCreate = ( + target: string | undefined, + ): UnifiedSearchTargetGroup => { + return findOrCreateForAliases(target ? [target] : [], target); + }; + + const findOrCreateForAliases = ( + aliases: string[], + target: string | undefined, + ): UnifiedSearchTargetGroup => { + const existing = groups.find((group) => + aliases.some((alias) => targetGroupMatches(group, alias)), + ); + if (existing) return existing; + const created: UnifiedSearchTargetGroup = { + identity: target ? { requested: target } : {}, + sources: [], + siteSuggestions: [], + trustLimits: [], + }; + groups.push(created); + return created; + }; + + for (const limit of input.trustLimits) { + if ( + limit.kind !== "stale" || + (!limit.requestedTarget && !limit.freshTarget && !limit.servedTarget) + ) { + continue; + } + const aliases = uniqueAliases([ + limit.requestedTarget, + limit.freshTarget, + limit.servedTarget, + limit.target, + ]); + const group = findOrCreateForAliases(aliases, aliases[0]); + if (limit.requestedTarget) group.identity.requested = limit.requestedTarget; + if (limit.freshTarget) group.identity.fresh = limit.freshTarget; + if (limit.servedTarget) group.identity.served = limit.servedTarget; + } + + for (const sourceGroup of input.sources) { + for (const entry of sourceGroup.entries) { + const group = findOrCreate(entry.searchTarget); + const existingSource = group.sources.find( + (candidate) => candidate.kind === sourceGroup.kind, + ); + if (existingSource) existingSource.entries.push(entry); + else group.sources.push({ kind: sourceGroup.kind, entries: [entry] }); + } + } + + for (const alternatives of input.alternatives) { + findOrCreate(alternatives.target).alternatives = alternatives; + } + for (const suggestion of input.siteSuggestions) { + findOrCreate(suggestion.target).siteSuggestions.push(suggestion); + } + for (const limit of input.trustLimits) { + if (limit.kind === "constraint" || limit.kind === "mutable_evidence") { + continue; + } + const target = "target" in limit ? limit.target : undefined; + const sourceGroup = groups.find((group) => + targetGroupMatches(group, target), + ); + const group = + sourceGroup ?? + (groups.length === 1 ? groups[0] : undefined) ?? + findOrCreate(target); + if (limit.kind === "stale") { + if (limit.requestedTarget) + group.identity.requested = limit.requestedTarget; + if (limit.freshTarget) group.identity.fresh = limit.freshTarget; + if (limit.servedTarget) group.identity.served = limit.servedTarget; + } + group.trustLimits.push(limit); + } + return groups.filter( + (group) => + targetIdentityValues(group.identity).length > 0 || + group.sources.length > 0 || + group.alternatives !== undefined || + group.siteSuggestions.length > 0 || + group.trustLimits.length > 0, + ); +} + +function targetIdentityValues( + identity: UnifiedSearchTargetPresentation, +): string[] { + return [identity.requested, identity.fresh, identity.served].filter( + (value): value is string => Boolean(value), + ); +} + +function targetGroupMatches( + group: UnifiedSearchTargetGroup, + target: string | undefined, +): boolean { + if (!target) return false; + return ( + targetIdentityValues(group.identity).includes(target) || + group.sources.some((source) => + source.entries.some( + (entry) => entry.target === target || entry.searchTarget === target, + ), + ) + ); +} + +function uniqueAliases(values: Array): string[] { + return [ + ...new Set(values.filter((value): value is string => Boolean(value))), + ]; +} + +export function targetDisplayFamilyKey(target: string | undefined): string { + if (!target) return ""; + const normalized = target + .trim() + .replace(/\s+latest$/, "") + .replace(/#[^#]+$/, ""); + return normalized.startsWith("npm:") + ? normalized.replace(/@[^/@]+$/, "") + : normalized.replace(/@[^#]+$/, ""); +} + function mergeAlternativeCandidates( candidates: CandidateSet[], ): CandidateSet[] { const merged: CandidateSet[] = []; for (const candidate of candidates) { - const key = candidate.target?.replace(/@[^/@]+$/, "") ?? ""; - const existing = merged.find( - (value) => (value.target?.replace(/@[^/@]+$/, "") ?? "") === key, + const existing = merged.find((value) => + candidate.aliases.some((alias) => value.aliases.includes(alias)), ); if (existing) { + existing.aliases = uniqueAliases([ + ...existing.aliases, + ...candidate.aliases, + ]); existing.versions.push(...candidate.versions); existing.refs.push(...candidate.refs); existing.suggestedRefs.push(...candidate.suggestedRefs); } else { merged.push({ target: candidate.target, + aliases: [...candidate.aliases], versions: [...candidate.versions], refs: [...candidate.refs], suggestedRefs: [...candidate.suggestedRefs], diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index cdb623e7..8d6b06a5 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -59,7 +59,7 @@ describe("renderUnifiedSearchStatusText", () => { "Indexing continues - 1 interim result returned", ); expect(text).toContain("[1] express/routing npm:express docs"); - expect(text).toContain("Do not repeat search.\nNext:"); + expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', ); @@ -99,13 +99,14 @@ describe("renderUnifiedSearchStatusText", () => { }, }), ); - expect(firstLine(text)).toBe( - "Preparing npm:express - no result snapshot returned yet", - ); - expect(text).not.toContain("Waiting:"); + expect(firstLine(text)).toBe("Preparing - no result snapshot yet"); + expect(text).not.toContain("Indexing:"); expect(text).not.toContain("No hits"); - expect(text).toContain("Ready: 0/1 targets"); - expect(text).toContain("Do not repeat search.\nNext:"); + expect(text).toContain("- npm:express"); + expect(text).toContain("Search search-ref-status | 0/1 target ready"); + expect(text).toContain( + 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', + ); }); it("renders a completed empty stored result with one applicable action", () => { @@ -124,10 +125,11 @@ describe("renderUnifiedSearchStatusText", () => { }; const text = renderUnifiedSearchStatusText(payload); expect(firstLine(text)).toContain("No results returned"); - expect(text).toContain("Searched: code"); - expect(text).toContain("Do not repeat this search unchanged."); - expect(text).toContain("shorten or broaden query"); - expect(text).not.toContain("search-ref-empty"); + expect(text).toContain("- npm:express@5.2.1\n Searched: code"); + expect(text).toContain( + 'Next: shorten or broaden query; use source="symbol"; use code_grep.', + ); + expect(text).toContain("Search search-ref-empty | completed"); }); it("continues completed mutable evidence through one status action", () => { @@ -141,12 +143,13 @@ describe("renderUnifiedSearchStatusText", () => { }; const text = renderUnifiedSearchStatusText(payload); expect(firstLine(text)).toContain("1 result"); - expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not repeat immediately.\nNext:"); + expect(text).toContain("Search search-ref-evidence | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); expect(text).not.toContain("opaque backend notice"); + expect(text).not.toContain("Evidence may change."); + expect(text).not.toContain("Do not repeat"); }); it.each(["DEFERRED", "TIMEOUT", "FAILED"] as const)( @@ -163,7 +166,8 @@ describe("renderUnifiedSearchStatusText", () => { }), ); expect(firstLine(text)).toStartWith(status); - expect(text).toContain("Do not poll this session again."); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); }, ); @@ -182,7 +186,8 @@ describe("renderUnifiedSearchStatusText", () => { expect(firstLine(text)).toBe( "FUTURE_SESSION_STATE - no result snapshot returned", ); - expect(text).toContain("Do not poll this session again."); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); }); }); diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 03fd69fb..a23a0e4c 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -204,16 +204,12 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(firstLine(text)).toContain("No results returned"); - expect(text).toContain("Searched: code"); - expect(text).toContain("Do not repeat this search unchanged."); - expect(text).toContain("shorten or broaden query"); - expect(text).toContain("remove restrictive filters"); - expect(text).toContain('source="symbol"'); - expect(text).toContain("code_grep"); - expect(text).not.toContain('query="'); - expect(text.match(/Do not repeat this search unchanged\./g)).toHaveLength( - 1, + expect(text).toContain("\n- npm:express@5.2.1\n Searched: code"); + expect(text).toContain( + 'Next: shorten or broaden query; remove restrictive filters; use source="symbol"; use code_grep.', ); + expect(text).not.toContain('query="'); + expect(text).not.toContain("Do not repeat"); }); it("renders symbol source readiness as code", () => { @@ -250,26 +246,22 @@ describe("renderUnifiedSearchSuccess", () => { it("renders the supplied n8n active empty snapshot with one concise readiness block", () => { const text = renderUnifiedSearchSuccess(n8nActiveEmpty()); - const lines = text.split("\n"); - expect(lines[0]).toBe("Indexing npm:n8n@2.36.7 - no results returned yet"); - expect(text).toContain("Ready: 0/1 targets"); - expect(text).toContain("Waiting: code, repository docs"); - expect(text).toContain( - "Available but not searched: n8n.io docs (1,480 pages; capped)", - ); - expect(text).toContain( - "Indexed alternatives: versions 2.26.9, 2.26.5, 2.23.2 +2 more; refs HEAD,\nmaster", - ); - expect(text).toContain( - 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', + expect(text).toBe( + "Indexing - no results yet\n\n" + + "- npm:n8n -> 2.36.7\n" + + " Indexing: code, repository docs | Ready now: n8n.io docs (not searched;\n" + + " 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD,\n" + + " master\n\n" + + "Search fabUr1S3MEVeSgD93pMoSQ | 0/1 target ready\n" + + 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', ); - expect(text).toContain("Do not repeat search.\nNext:"); + expect(text).not.toContain("Do not repeat"); expect(text).not.toContain("indexingRef"); expect(text).not.toContain("freshnessReason"); expect(text).not.toContain("Opaque evidence notice"); - expect(text.match(/Indexing/g)).toHaveLength(1); - expect(text.match(/Ready:/g)).toHaveLength(1); + expect(text.match(/Indexing/g)).toHaveLength(2); + expect(text.match(/Ready now:/g)).toHaveLength(1); expect(text.match(/Next:/g)).toHaveLength(1); }); @@ -354,8 +346,8 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot returned yet"); - expect(text).toContain("Ready: 0/2 targets"); + expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); + expect(text).toContain("Search ref_abc-123 | 0/2 targets ready"); }); it("does not invent source details for a true progress-only response", () => { @@ -378,15 +370,15 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe( - "Indexing npm:n8n@2.36.7 - no result snapshot returned yet", - ); - expect(text).toContain("Ready: 0/1 targets"); + expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); + expect(text).toContain("Search ref_abc-123 | 0/1 target ready"); expect(text).not.toContain("Waiting:"); - expect(text).not.toContain("Available but not searched:"); + expect(text).not.toContain("Searched:"); expect(text).not.toContain("n8n.io"); - expect(text).toContain("Indexed alternatives: versions 2.26.9"); - expect(text).toContain("Do not repeat search.\nNext:"); + expect(text).toContain("versions 2.26.9"); + expect(text).toContain( + 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', + ); }); it("renders an initial progress-only parser warning once below the outcome", () => { @@ -396,7 +388,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot returned yet"); + expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); expect(text).toContain("Warnings:\n - unknown qualifier"); expect(text.match(/unknown qualifier/g)).toHaveLength(1); expect(text.indexOf("Warnings:")).toBeGreaterThan(0); @@ -430,10 +422,10 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Searched: site docs (example.com/reference)"); expect(text).toContain( - "Available but not searched: example.com/guide docs", + "Searched: example.com/reference docs | Ready now: example.com/guide docs", ); + expect(text).toContain("(not searched)"); expect(text).not.toContain("for npm:example@1.0.0"); }); @@ -451,15 +443,14 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Suggested site targets: site:docs.example.com, site:api.example.com", + "Suggested sites: site:docs.example.com,\n site:api.example.com | More suggested sites omitted", ); - expect(text).toContain("Additional site targets were omitted."); - expect(text).toContain("Do not repeat search.\nNext: search_status"); - expect(text).not.toContain("Next: retry one suggested site target"); - expect(text.match(/Suggested site targets:/g)).toHaveLength(1); - expect(text.match(/Additional site targets were omitted\./g)).toHaveLength( - 1, + expect(text).toContain( + 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', ); + expect(text).not.toContain("Next: retry one suggested site target"); + expect(text.match(/Suggested sites:/g)).toHaveLength(1); + expect(text.match(/More suggested sites omitted/g)).toHaveLength(1); }); it("does not suffix deduplicated site suggestions with a target", () => { @@ -479,8 +470,8 @@ describe("renderUnifiedSearchSuccess", () => { incomplete({ partialResults: false, sourceStatus }), ); - expect(text).toContain("Suggested site targets: site:docs.example.com"); - expect(text).not.toContain("Suggested site targets for site:example.com:"); + expect(text).toContain("Suggested sites: site:docs.example.com"); + expect(text).not.toContain("Suggested sites for site:example.com:"); }); it("renders site retry guidance for completed and terminal site recovery", () => { @@ -495,9 +486,7 @@ describe("renderUnifiedSearchSuccess", () => { const completedText = renderUnifiedSearchSuccess( completed([], { sourceStatus }), ); - expect(completedText).toContain( - "Suggested site targets: site:docs.example.com", - ); + expect(completedText).toContain("Suggested sites: site:docs.example.com"); expect(completedText).toContain( "Next: retry one suggested site target explicitly.", ); @@ -515,13 +504,10 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(terminalText).toContain( - "Suggested site targets: site:docs.example.com", - ); + expect(terminalText).toContain("Suggested sites: site:docs.example.com"); expect(terminalText).toContain( "Next: retry one suggested site target explicitly.", ); - expect(terminalText).toContain("Do not poll this session again."); expect(terminalText).not.toContain("Next: search_status"); }); @@ -583,19 +569,10 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toBe("No results returned"); expect(text).toContain( - "Waiting: code for npm:one@1.0.0, code for npm:two@2.0.0", - ); - expect(text).toMatch( - /repository docs \(https:\/\/github\.com\/one\/repo @ commit-one\) for\nnpm:one@1\.0\.0/, + "- npm:one@1.0.0\n Indexing: code | Searched: repository docs, docs.one.example docs", ); - expect(text).toMatch( - /repository docs \(https:\/\/github\.com\/two\/repo @ commit-two\)\nfor npm:two@2\.0\.0/, - ); - expect(text).toMatch( - /site docs \(docs\.one\.example\) for npm:one@1\.0\.0/, - ); - expect(text).toMatch( - /site docs\n\(docs\.two\.example\) for npm:two@2\.0\.0/, + expect(text).toContain( + "- npm:two@2.0.0\n Indexing: code | Searched: repository docs, docs.two.example docs", ); }); @@ -623,9 +600,11 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Waiting: code for npm:one@1.0.0"); - expect(text).toContain("Searched: site docs (site:docs.one.example)"); - expect(text).not.toContain("site docs (site:docs.one.example) for site:"); + expect(text).toContain("- npm:one@1.0.0\n Indexing: code"); + expect(text).toContain( + "- site:docs.one.example\n Searched: site:docs.one.example docs", + ); + expect(text).not.toContain("for site:"); }); it("does not repeat exact identities for unavailable code or available sites", () => { @@ -656,17 +635,15 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Unavailable: code (npm:one@1.0.0)"); + expect(text).toContain("- npm:one@1.0.0\n Unavailable: code"); expect(text).not.toContain( "Unavailable: code (npm:one@1.0.0) for npm:one@1.0.0", ); - expect(text).toContain("Searched: code for npm:two@2.0.0"); + expect(text).toContain("- npm:two@2.0.0\n Searched: code"); expect(text).toContain( - "Available but not searched: site:docs.one.example docs", - ); - expect(text).not.toMatch( - /Available but not searched: site:docs\.one\.example docs for\s+site:docs\.one\.example/, + "- site:docs.one.example\n Ready now: site:docs.one.example docs (not searched)", ); + expect(text).not.toContain("for site:"); }); it("omits a singular outcome target when hits span multiple targets", () => { @@ -699,8 +676,8 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toStartWith(label); - expect(firstLine(text)).toContain("no result snapshot returned yet"); - expect(firstLine(text)).not.toContain("No results returned yet"); + expect(firstLine(text)).toContain("no result snapshot yet"); + expect(firstLine(text)).not.toContain("No results yet"); }, ); @@ -741,7 +718,8 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toStartWith(status); - expect(text).toContain("Do not poll this session again."); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); }, ); @@ -760,7 +738,8 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toBe( "FUTURE_SESSION_STATE - no result snapshot returned", ); - expect(text).toContain("Do not poll this session again."); + expect(text).toContain("Next: rerun search later."); + expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); expect(text).not.toContain("indexing"); }); @@ -786,9 +765,9 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(text).toContain("Evidence:"); - expect(text).toContain("older snapshot"); - expect(text).toContain("provisional"); + expect(text).toContain("- npm:express latest -> 5.2.1"); + expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes | Searched: code"); + expect(text).not.toContain("Evidence:"); expect(text).not.toContain("idx-hidden"); expect(text).not.toContain("exact_provisional"); expect(text).not.toContain("shorten or broaden query"); @@ -820,11 +799,10 @@ describe("renderUnifiedSearchSuccess", () => { ), ); - expect(firstLine(text)).toBe("1 result from npm:express@5.1.0"); - expect(text.match(/Evidence:/g)).toHaveLength(1); - expect(text).toContain( - "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", - ); + expect(firstLine(text)).toBe("1 result"); + expect(text).toContain("- npm:express latest -> 5.2.1"); + expect(text.match(/Using:/g)).toHaveLength(1); + expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); }); it("treats indexing hit freshness as stale served evidence", () => { @@ -840,11 +818,61 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); - expect(firstLine(text)).toBe("1 result from npm:express@5.1.0"); - expect(text.match(/Evidence:/g)).toHaveLength(1); + expect(firstLine(text)).toBe("1 result"); + expect(text).toContain("- npm:express latest -> 5.2.1"); + expect(text.match(/Using:/g)).toHaveLength(1); + expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + }); + + it("shows a served older version from progress-only stale identity", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 20, + targets: [ + { + requested: "npm:express latest", + resolvedRequested: "npm:express@5.2.1", + served: "npm:express@5.1.0", + freshness: "INDEXING", + }, + ], + }, + }), + ); + + expect(text).toContain("- npm:express latest -> 5.2.1"); + expect(text.match(/Using:/g)).toHaveLength(1); + expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).not.toContain("(using"); + }); + + it("keeps a stale served version in detail when no fresh target exists", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 20, + targets: [ + { + requested: "npm:express latest", + served: "npm:express@5.1.0", + freshness: "INDEXING", + }, + ], + }, + }), + ); + expect(text).toContain( - "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", + "- npm:express latest\n Using: 5.1.0 (older snapshot)", ); + expect(text).not.toContain("- npm:express latest -> 5.1.0"); }); it("uses the searched package context for a lone docpack outcome", () => { @@ -868,7 +896,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned from npm:express@5.2.1"); + expect(firstLine(text)).toBe("No results returned"); }); it.each([ @@ -901,9 +929,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe( - "No results returned from npm:express@5.1.0", - ); + expect(firstLine(text)).toBe("No results returned"); expect(firstLine(text)).not.toContain(targetLabel); expect(firstLine(text)).not.toContain(freshTarget); }, @@ -917,13 +943,13 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toBe("No results returned"); - expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not repeat immediately.\nNext:"); + expect(text).toContain("Search search-ref-evidence | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); expect(text).not.toContain("Opaque backend prose"); - expect(text).not.toContain("Do not repeat this search unchanged."); + expect(text).not.toContain("Evidence may change."); + expect(text).not.toContain("Do not repeat"); }); it("continues completed mutable evidence with hits through the exact reference", () => { @@ -934,15 +960,16 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toContain("1 result"); - expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not repeat immediately.\nNext:"); + expect(text).toContain("Search search-ref-results | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-results" wait_timeout_ms=20000', ); const lines = text.split("\n"); - const actionLine = lines.indexOf("Do not repeat immediately."); + const actionLine = lines.findIndex((line) => line.startsWith("Next: ")); expect(actionLine).toBeGreaterThan(0); - expect(lines[actionLine - 1]).toBe(""); + expect(lines[actionLine - 1]).toBe("Search search-ref-results | completed"); + expect(text).not.toContain("Evidence may change."); + expect(text).not.toContain("Do not repeat"); }); it("prints query and structured constraint warnings once below the outcome", () => { @@ -1063,7 +1090,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("[1] cline/cline@v3.4.2"); expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); expect(text).toContain( - "Indexed alternatives: versions 5.2.1, 5.2.0, 5.1.0 +1 more; refs HEAD, main,\nnext +1 more", + "Ready now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", ); expect(text).toContain("More hits available. Pass offset=10"); expect(cliText).toContain( @@ -1126,16 +1153,17 @@ describe("renderUnifiedSearchSuccess", () => { const lines = text.split("\n"); const summaryLines = lines.filter((line) => - /^(Waiting|Searched|Indexed alternatives|Suggested site targets)/.test( - line, - ), + /^( {2})?(Indexing|Searched|Ready now|Suggested sites)/.test(line), ); - expect(summaryLines.length).toBeGreaterThan(3); + expect(summaryLines.length).toBeGreaterThanOrEqual(3); expect(summaryLines.every((line) => line.length <= 76)).toBe(true); expect(text).toContain(targetOne); expect(text).toContain(targetTwo); expect(text).toContain(longRef); - expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain("More suggested sites omitted"); + expect(text).toContain( + "Next: search indexed version 1.0.0 for npm:one-long-package@1.0.0.", + ); const overlongLines = lines.filter((line) => line.length > 76); expect(overlongLines).toHaveLength(1); @@ -1168,7 +1196,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(text).toContain( - "Searched: site docs (docs.example.com; 120 pages; partial)", + "Searched: docs.example.com docs (120 pages; partial)", ); expect(text.match(/120 pages/g)).toHaveLength(1); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index aacef6b8..67f23278 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -21,11 +21,13 @@ import { colors, dim, highlight, highlightRanges } from "./colors.js"; import { buildSearchHitFollowUpCommand } from "./follow-up-command-text.js"; import { projectUnifiedSearchPresentation, + targetDisplayFamilyKey, type UnifiedSearchAction, type UnifiedSearchLifecycle, type UnifiedSearchPresentation, type UnifiedSearchSourceEntry, type UnifiedSearchSourceGroup, + type UnifiedSearchTargetGroup, type UnifiedSearchTrustLimit, type UnifiedSearchWarning, } from "./unified-search-presentation.js"; @@ -86,8 +88,8 @@ export function renderUnifiedSearchPresentationText( const hasPostResultBlock = presentation.hasMore || - presentation.alternatives.length > 0 || - presentation.siteSuggestions.length > 0 || + presentation.searchRef !== undefined || + presentation.progress !== undefined || presentation.action.kind !== "none"; if ( result.results.length > 0 && @@ -106,8 +108,7 @@ export function renderUnifiedSearchPresentationText( lines.push(nextOffsetHint); } - appendPresentationAlternatives(lines, presentation, settings); - appendPresentationSiteSuggestions(lines, presentation, settings); + appendPresentationSession(lines, presentation, settings); appendPresentationAction(lines, presentation, settings); return lines.join("\n"); } @@ -154,14 +155,14 @@ function formatPresentationOutcome( const label = activeLifecycleLabel(presentation.lifecycle); if (presentation.availability.kind === "no_snapshot") { return styleOutcome( - `${label}${targetSuffix} - no result snapshot returned yet`, + `${label}${targetSuffix} - no result snapshot yet`, presentation, options.useColors, ); } if (presentation.availability.kind === "empty") { return styleOutcome( - `${label}${targetSuffix} - no results returned yet`, + `${label}${targetSuffix} - no results yet`, presentation, options.useColors, ); @@ -245,6 +246,7 @@ function presentationTarget( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], ): string | undefined { + if (presentation.targetGroups.length > 0) return undefined; if (presentation.targets.length > 1) return undefined; if (results.length > 0) { const sourceTargets = presentation.sources.flatMap((group) => @@ -274,118 +276,136 @@ function appendPresentationContext( presentation: UnifiedSearchPresentation, options: NormalizedTextOptions, ): void { - if (presentation.progress) { - lines.push( - `Ready: ${presentation.progress.targetsReady}/${presentation.progress.targetsTotal} targets`, - ); + if (presentation.targetGroups.length > 0) { + lines.push(""); + presentation.targetGroups.forEach((group, index) => { + if (index > 0) lines.push(""); + appendPresentationTargetGroup(lines, group, options); + }); } - appendPresentationTargetDivergence(lines, presentation); - appendPresentationSources( - lines, - presentation.sources, - presentation.trustLimits, - ); - appendPresentationTrust(lines, presentation.trustLimits, options); appendPresentationWarnings(lines, presentation.warnings, options); } -function appendPresentationTargetDivergence( +function appendPresentationTargetGroup( lines: string[], - presentation: UnifiedSearchPresentation, + group: UnifiedSearchTargetGroup, + options: NormalizedTextOptions, ): void { - for (const target of presentation.targets) { - const identities = [target.requested, target.fresh, target.served].filter( - (value): value is string => Boolean(value), + const identity = `- ${formatTargetGroupIdentity(group)}`; + lines.push(options.useColors ? highlight(identity, true) : identity); + + const details: string[] = []; + const stale = group.trustLimits + .filter( + (limit): limit is Extract => + limit.kind === "stale", + ) + .sort( + (left, right) => + Number(Boolean(right.servedTarget)) + + Number(Boolean(right.freshTarget)) - + Number(Boolean(left.servedTarget)) - + Number(Boolean(left.freshTarget)), + )[0]; + const identityIsStale = + !stale && + Boolean(group.identity.served) && + ["STALE", "INDEXING", "stale", "indexing", "fallback_recent"].includes( + group.identity.freshness ?? "", + ) && + group.identity.served !== + (group.identity.fresh ?? group.identity.requested); + if (stale || identityIsStale) { + const served = + stale?.servedTarget ?? stale?.target ?? group.identity.served; + const fresh = stale?.freshTarget ?? group.identity.fresh; + details.push( + `Using: ${compactRelatedTarget(group.identity.requested, served ?? "older snapshot")}${fresh ? ` while ${compactRelatedTarget(group.identity.requested, fresh)} indexes` : " (older snapshot)"}`, ); - if (new Set(identities).size < 2) continue; - const parts: string[] = []; - if (target.requested) parts.push(`requested ${target.requested}`); - if (target.fresh) parts.push(`fresh ${target.fresh}`); - if (target.served) parts.push(`served ${target.served}`); - if (parts.length > 0) lines.push(`Target: ${parts.join("; ")}`); + } else if (group.trustLimits.some((limit) => limit.kind === "provisional")) { + details.push("Indexing: provisional snapshot is searchable"); } -} -function appendPresentationSources( - lines: string[], - groups: UnifiedSearchSourceGroup[], - trustLimits: UnifiedSearchTrustLimit[], -): void { const states: Array<{ state: UnifiedSearchSourceEntry["state"]; label: string; }> = [ - { state: "waiting", label: "Waiting" }, + { state: "waiting", label: "Indexing" }, { state: "searched", label: "Searched" }, - { state: "available_not_searched", label: "Available but not searched" }, + { state: "available_not_searched", label: "Ready now" }, { state: "unavailable", label: "Unavailable" }, ]; - const contextTargets = new Set( - groups.flatMap((group) => group.entries.map((entry) => entry.searchTarget)), - ); - const showTargetContext = contextTargets.size > 1; for (const { state, label } of states) { - const entries = groups.flatMap((group) => - group.entries + const entries = group.sources.flatMap((source) => + source.entries .filter((entry) => entry.state === state) - .map((entry) => ({ group, entry })), + .map((entry) => ({ source, entry })), ); if (entries.length === 0) continue; - const values = entries.map(({ group, entry }) => - formatSourceReadiness(group, entry, trustLimits, showTargetContext), + const values = entries.map(({ source, entry }) => + formatGroupedSource(source, entry, group.trustLimits), ); - const unique = [...new Set(values)]; - lines.push(...wrapText(`${label}: ${unique.join(", ")}`)); + details.push(`${label}: ${[...new Set(values)].join(", ")}`); + } + + if ( + details.length === 0 && + ["INDEXING", "PENDING", "PROVISIONAL", "indexing", "provisional"].includes( + group.identity.freshness ?? "", + ) + ) { + details.push("Indexing"); + } + + const ready = formatTargetAlternatives(group.alternatives); + if (ready) { + const readyIndex = details.findIndex((detail) => + detail.startsWith("Ready now:"), + ); + if (readyIndex >= 0) + details[readyIndex] = `${details[readyIndex]}, ${ready}`; + else details.push(`Ready now: ${ready}`); + } + + const suggestions = [ + ...new Set(group.siteSuggestions.flatMap((item) => item.suggestions)), + ]; + if (suggestions.length > 0) { + details.push(`Suggested sites: ${suggestions.join(", ")}`); + } + if (group.siteSuggestions.some((item) => item.truncated)) { + details.push("More suggested sites omitted"); + } + + if (details.length > 0) { + lines.push(...wrapHangingText(details.join(" | "), " ")); } } -function formatSourceReadiness( - group: UnifiedSearchSourceGroup, +function formatGroupedSource( + source: UnifiedSearchSourceGroup, entry: UnifiedSearchSourceEntry, trustLimits: UnifiedSearchTrustLimit[], - showTargetContext: boolean, ): string { - const sourceLabel = sourceGroupLabel(group.kind); - const contextSuffix = (identity?: string): string => - showTargetContext && identity !== entry.searchTarget - ? ` for ${entry.searchTarget}` - : ""; - if (entry.state === "unavailable") { - return `${sourceLabel} (${entry.target})${contextSuffix(entry.target)}`; - } const coverage = trustLimits.find( (limit): limit is Extract => limit.kind === "coverage" && - limit.source === group.kind && + limit.source === source.kind && limit.target === entry.target, ); const coverageDetails = coverage ? formatCoverageLimit(coverage) : undefined; - if (entry.state === "searched") { - const identity = - group.kind === "code" - ? undefined - : formatDocumentationSourceIdentity(group, entry); - const details = [identity, coverageDetails].filter( - (value): value is string => Boolean(value), - ); - return `${sourceLabel}${details.length > 0 ? ` (${details.join("; ")})` : ""}${contextSuffix(identity)}`; - } - if (entry.state === "waiting") { - const identity = - showTargetContext && group.kind !== "code" - ? formatDocumentationSourceIdentity(group, entry) - : undefined; - return `${sourceLabel}${identity ? ` (${identity})` : ""}${contextSuffix(identity)}`; - } - const baseIdentity = - group.kind === "site_docs" - ? formatDocumentationSourceIdentity(group, entry) - : entry.target; const identity = - group.kind === "site_docs" - ? `${baseIdentity} docs` - : `${sourceLabel} (${baseIdentity})`; - return `${identity}${coverageDetails ? ` (${coverageDetails})` : ""}${contextSuffix(baseIdentity)}`; + source.kind === "code" + ? "code" + : source.kind === "repository_docs" + ? "repository docs" + : source.kind === "site_docs" + ? `${formatDocumentationSourceIdentity(source, entry)} docs` + : `docs (${entry.target})`; + const qualifiers: string[] = []; + if (entry.state === "available_not_searched") qualifiers.push("not searched"); + if (coverageDetails) qualifiers.push(coverageDetails); + return `${identity}${qualifiers.length > 0 ? ` (${qualifiers.join("; ")})` : ""}`; } function formatDocumentationSourceIdentity( @@ -400,19 +420,6 @@ function formatDocumentationSourceIdentity( return siteIdentity ?? entry.siteKey ?? entry.target; } -function sourceGroupLabel(kind: UnifiedSearchSourceGroup["kind"]): string { - switch (kind) { - case "docs": - return "docs"; - case "repository_docs": - return "repository docs"; - case "site_docs": - return "site docs"; - case "code": - return "code"; - } -} - function formatCoverageLimit( limit: Extract, ): string { @@ -423,39 +430,60 @@ function formatCoverageLimit( return details.join("; "); } -function appendPresentationTrust( - lines: string[], - trustLimits: UnifiedSearchTrustLimit[], - options: NormalizedTextOptions, -): void { - const trust = trustLimits.filter((limit) => limit.kind !== "source"); - for (const limit of trust) { - switch (limit.kind) { - case "stale": - lines.push( - dim( - `Evidence: ${limit.requestedTarget ? `requested ${limit.requestedTarget}; ` : ""}served older snapshot ${limit.servedTarget ?? limit.target ?? "unknown target"}${limit.freshTarget ? ` while ${limit.freshTarget} indexes` : ""}.`, - options.useColors, - ), - ); - break; - case "provisional": - lines.push( - dim( - "Evidence: provisional snapshot; indexing continues.", - options.useColors, - ), - ); - break; - case "coverage": - break; - case "constraint": - case "mutable_evidence": - if (limit.kind === "mutable_evidence") - lines.push(dim("Evidence may change.", options.useColors)); - break; - } +function formatTargetGroupIdentity(group: UnifiedSearchTargetGroup): string { + const { requested, fresh, served } = group.identity; + const primary = requested ?? fresh ?? served ?? "target"; + const staleLike = + group.trustLimits.some((limit) => limit.kind === "stale") || + ["STALE", "INDEXING", "stale", "indexing", "fallback_recent"].includes( + group.identity.freshness ?? "", + ); + const resolved = fresh ?? (staleLike ? undefined : served); + const resolution = + resolved && resolved !== primary + ? ` -> ${compactRelatedTarget(primary, resolved)}` + : ""; + return `${primary}${resolution}`; +} + +function compactRelatedTarget(base: string | undefined, value: string): string { + if (!base) return value; + if (targetDisplayFamilyKey(base) !== targetDisplayFamilyKey(value)) { + return value; + } + const version = value.match(/@([^/@]+)$/)?.[1]; + if (version) return version; + const ref = value.match(/#([^#]+)$/)?.[1]; + return ref ?? value; +} + +function formatTargetAlternatives( + alternatives: UnifiedSearchTargetGroup["alternatives"], +): string | undefined { + if (!alternatives) return undefined; + const categories: string[] = []; + if (alternatives.versions.length > 0) { + categories.push( + `versions ${alternatives.versions.map((entry) => entry.version ?? entry.ref).join(", ")}${formatRemaining(alternatives.versionsRemaining)}`, + ); } + if (alternatives.refs.length > 0) { + categories.push( + `refs ${alternatives.refs.map((entry) => entry.ref).join(", ")}${formatRemaining(alternatives.refsRemaining)}`, + ); + } + if (alternatives.suggestedRefs.length > 0) { + categories.push( + `suggested refs ${alternatives.suggestedRefs.map((entry) => entry.ref).join(", ")}${formatRemaining(alternatives.suggestedRefsRemaining)}`, + ); + } + return categories.length > 0 ? categories.join(", ") : undefined; +} + +function wrapHangingText(text: string, prefix: string): string[] { + return wrapText(text, SUMMARY_WRAP_WIDTH - prefix.length).map( + (line) => `${prefix}${line}`, + ); } function appendPresentationWarnings( @@ -487,69 +515,34 @@ function appendPresentationWarnings( } } -function appendPresentationAlternatives( +function appendPresentationSession( lines: string[], presentation: UnifiedSearchPresentation, options: NormalizedTextOptions, ): void { - for (const alternative of presentation.alternatives) { - const categories: string[] = []; - if (alternative.versions.length > 0) { - categories.push( - `versions ${alternative.versions.map((entry) => entry.version ?? entry.ref).join(", ")}${formatRemaining(alternative.versionsRemaining)}`, - ); - } - if (alternative.refs.length > 0) { - categories.push( - `refs ${alternative.refs.map((entry) => entry.ref).join(", ")}${formatRemaining(alternative.refsRemaining)}`, - ); - } - if (alternative.suggestedRefs.length > 0) { - categories.push( - `suggested refs ${alternative.suggestedRefs.map((entry) => entry.ref).join(", ")}${formatRemaining(alternative.suggestedRefsRemaining)}`, - ); - } - if (categories.length > 0) { - lines.push( - ...wrapText( - `Indexed alternatives${presentation.alternatives.length > 1 && alternative.target ? ` for ${alternative.target}` : ""}: ${categories.join("; ")}`, - SUMMARY_WRAP_WIDTH, - ).map((line) => dim(line, options.useColors)), - ); - } + const parts: string[] = []; + if (presentation.searchRef) parts.push(`Search ${presentation.searchRef}`); + if (presentation.progress) { + const { targetsReady, targetsTotal } = presentation.progress; + parts.push( + `${targetsReady}/${targetsTotal} target${targetsTotal === 1 ? "" : "s"} ready`, + ); + } else if (presentation.searchRef) { + parts.push(formatLifecycleSummary(presentation.lifecycle)); } + if (parts.length === 0) return; + if (lines[lines.length - 1] !== "") lines.push(""); + lines.push(dim(parts.join(" | "), options.useColors)); } -function appendPresentationSiteSuggestions( - lines: string[], - presentation: UnifiedSearchPresentation, - options: NormalizedTextOptions, -): void { - const seen = new Set(); - const rendered = presentation.siteSuggestions.flatMap((facts) => { - const suggestions = facts.suggestions.filter((suggestion) => { - if (seen.has(suggestion)) return false; - seen.add(suggestion); - return true; - }); - return suggestions.length > 0 ? [{ facts, suggestions }] : []; - }); - for (const { facts, suggestions } of rendered) { - const targetSuffix = rendered.length > 1 ? ` for ${facts.target}` : ""; - lines.push( - ...wrapText( - `Suggested site targets${targetSuffix}: ${suggestions.join(", ")}`, - SUMMARY_WRAP_WIDTH, - ).map((line) => dim(line, options.useColors)), - ); - } - if (presentation.siteSuggestions.some((facts) => facts.truncated)) { - lines.push(dim("Additional site targets were omitted.", options.useColors)); - } +function formatLifecycleSummary(lifecycle: UnifiedSearchLifecycle): string { + if (lifecycle.kind === "completed") return "completed"; + if (lifecycle.kind === "active") return lifecycle.status.toLowerCase(); + return lifecycle.status?.toLowerCase() ?? "status unknown"; } function formatRemaining(count: number): string { - return count > 0 ? ` +${count} more` : ""; + return count > 0 ? ` +${count}` : ""; } function appendPresentationAction( @@ -558,22 +551,15 @@ function appendPresentationAction( options: NormalizedTextOptions, ): void { const action = presentation.action; - if (action.kind === "none") { - if (presentation.availability.kind === "empty") { - lines.push( - hasEvidenceLimit(presentation.trustLimits) - ? "Do not repeat immediately." - : "Do not repeat this search unchanged.", - ); - } - return; + if (action.kind === "none") return; + if ( + presentation.searchRef === undefined && + presentation.progress === undefined && + lines[lines.length - 1] !== "" + ) { + lines.push(""); } if (action.kind === "poll" || action.kind === "status") { - lines.push( - action.kind === "status" - ? "Do not repeat immediately." - : "Do not repeat search.", - ); const next = options.actionSyntax === "cli" ? `Next: githits search-status ${action.searchRef} --wait ${DEFAULT_WAIT_TIMEOUT_MS / 1000}` @@ -582,42 +568,20 @@ function appendPresentationAction( return; } if (action.kind === "new_search") { - if ( - presentation.lifecycle.kind === "terminal" || - presentation.lifecycle.kind === "unknown" - ) { - lines.push("Do not poll this session again."); - } else if (presentation.availability.kind === "empty") { - lines.push("Do not repeat immediately."); - } lines.push("Next: rerun search later."); return; } if (action.kind === "indexed_alternative") { - if (presentation.availability.kind === "empty") { - lines.push("Do not repeat immediately."); - } lines.push( `Next: search indexed ${action.category} ${action.value}${action.target ? ` for ${action.target}` : ""}.`, ); return; } if (action.kind === "site_retry") { - lines.push( - presentation.lifecycle.kind === "terminal" || - presentation.lifecycle.kind === "unknown" - ? "Do not poll this session again." - : "Do not repeat immediately.", - ); lines.push("Next: retry one suggested site target explicitly."); return; } if (action.kind === "query_rewrite") { - lines.push( - hasEvidenceLimit(presentation.trustLimits) - ? "Do not repeat immediately." - : "Do not repeat this search unchanged.", - ); lines.push( `Next: ${action.rewrites .map((rewrite) => formatRewrite(rewrite, options.actionSyntax)) @@ -626,17 +590,6 @@ function appendPresentationAction( } } -function hasEvidenceLimit(trustLimits: UnifiedSearchTrustLimit[]): boolean { - return trustLimits.some( - (limit) => - limit.kind === "mutable_evidence" || - limit.kind === "stale" || - limit.kind === "provisional" || - limit.kind === "coverage" || - limit.kind === "source", - ); -} - function formatRewrite( rewrite: NonNullable< Extract diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 0e674cc2..c69b322e 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -7,6 +7,7 @@ import { EXPECTED_MCP_TOOLS, type McpSmokeCaller, type McpSmokeToolResult, + resultText, runMcpSmoke, } from "./smoke-test.js"; @@ -171,6 +172,203 @@ describe("runMcpSmoke", () => { "search default: search_ref= must appear at most once", ); }); + + it.each([ + ["status: indexing", "duplicated lifecycle status line"], + ["searchRef=leaked", "leaked searchRef="], + ["indexingRef=leaked", "leaked indexingRef"], + ])( + "rejects top-level formatter diagnostic %s", + async (diagnostic, message) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult(`${smokeSearchText()}\n${diagnostic}`); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + `search default: ${message}`, + ); + }, + ); + + it.each([ + ["Ready:", "legacy flat section Ready:"], + ["Waiting:", "legacy flat section Waiting:"], + [ + "Available but not searched:", + "legacy flat section Available but not searched:", + ], + ["Indexed alternatives:", "legacy flat section Indexed alternatives:"], + ])("rejects legacy flat search section %s", async (section, message) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + smokeSearchText().replace( + " Indexing: code | Ready now: versions 5.2.1", + `${section} 0/1 targets`, + ), + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + `search default: ${message}`, + ); + }); + + it.each([ + ["Evidence may change.", "vague evidence policy prose"], + ["Do not repeat search.", "repeat policy prose"], + ["Do not poll this session.", "poll policy prose"], + ])("rejects superseded search prose %s", async (prose, message) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult(`${smokeSearchText()}\n${prose}`); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + `search default: ${message}`, + ); + }); + + it("requires readiness details to be grouped under a target", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + smokeSearchText().replace("\n- npm:express@5.2.1", ""), + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: readiness details must be grouped under a target", + ); + }); + + it("requires an outcome headline before search details", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + smokeSearchText().replace( + "Indexing - no result snapshot yet", + "Warnings:", + ), + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: missing outcome headline", + ); + }); + + it("ignores formatter-like words inside indented hit content", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "1 result\n\n[1] npm:express@5.2.1 code\n" + + ' code_read target="npm:express@5.2.1" path="index.js"\n' + + " Ready: payload text\n" + + " Waiting: payload text\n" + + " Available but not searched: payload text\n" + + " Indexed alternatives: payload text\n" + + " Evidence may change.\n" + + " Do not repeat this payload.\n" + + " Do not poll this payload.\n" + + " Next: payload text\n" + + " Indexing: payload text\n" + + " status: payload text\n" + + " searchRef=payload text\n" + + " indexingRef payload text\n" + + " search_ref=payload text", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it("allows completed hit text without a target group", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "1 result\n\n[1] npm:express@5.2.1 code\n" + + ' code_read target="npm:express@5.2.1" path="index.js"', + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it("allows completed documentation hit text without a target group", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "1 result\n\n[1] docs.example.com/readme documentation\n" + + ' docs_read page_id="docs.example.com/readme"', + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it.each([ + [ + "1 result\n\n[1] npm:express@5.2.1 code\n" + + " This payload mentions code_read but has no locator", + ], + [ + "1 result\n\n[1] npm:express@5.2.1 code\n" + + ' code_read target="npm:express@5.2.1"', + ], + [ + "1 result\n\n[1] docs.example.com/readme documentation\n" + + ' docs_read page_id=""', + ], + [ + "1 result\n\n[1] npm:express@5.2.1 code\n" + + " ordinary title\n" + + ' code_read target="npm:express@5.2.1" path="index.js"', + ], + ])("rejects incomplete or prose-only hit follow-ups", async (searchText) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult(searchText); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: missing ready-to-call result or status follow-up", + ); + }); + + it("rejects duplicate lifecycle outcome lines", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + `${smokeSearchText()}\nIndexing - no result snapshot yet`, + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: duplicate lifecycle outcome lines", + ); + }); }); function smokeResponse( @@ -226,7 +424,11 @@ function smokeResponse( return textResult("package.json: express"); case "search": return textResult( - 'Indexing - no result snapshot returned yet\nReady: 0/1 targets\nDo not repeat search.\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000', + "Indexing - no result snapshot yet\n\n" + + "- npm:express@5.2.1\n" + + " Indexing: code | Ready now: versions 5.2.1\n\n" + + "Search smoke-ref | 0/1 target ready\n" + + 'Next: search_status search_ref="smoke-ref" wait_timeout_ms=20000', ); case "search_status": return errorResult("NOT_FOUND"); @@ -240,6 +442,10 @@ function smokeResponse( } } +function smokeSearchText(): string { + return resultText(smokeResponse("search", {}), "search fixture"); +} + function smokeJsonResponse( name: string, args: Record, diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 4b2d6cc7..98e4dbd9 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -182,40 +182,154 @@ export function assertDefaultText( function assertSearchDefaultText(text: string, context: string): void { const lines = text.split("\n"); + const formatterLines = lines.filter((line) => !line.startsWith(" ")); + const formatterText = formatterLines.join("\n"); const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); + assert( + /^(?:Preparing|Indexing|Searching)\b|^No results returned\b|^\d+ results?\b|^[A-Z_]+ - /.test( + firstLine, + ), + `${context}: missing outcome headline`, + ); assert( !firstLine.startsWith("search |") && !firstLine.startsWith("search_status |"), `${context}: legacy header precedes outcome`, ); assert( - !lines.some((line) => /^status\s*:/i.test(line.trim())), + !formatterLines.some((line) => /^status\s*:/i.test(line.trim())), `${context}: duplicated lifecycle status line`, ); - assert(!text.includes("searchRef="), `${context}: leaked searchRef=`); - assert(!text.includes("indexingRef"), `${context}: leaked indexingRef`); + const lifecycleOutcomeLines = lines.filter((line) => + /^(?:Preparing|Indexing|Searching)\b/.test(line), + ); + assert( + lifecycleOutcomeLines.length <= 1, + `${context}: duplicate lifecycle outcome lines`, + ); + assert( + !formatterText.includes("searchRef="), + `${context}: leaked searchRef=`, + ); + assert( + !formatterText.includes("indexingRef"), + `${context}: leaked indexingRef`, + ); + + const forbiddenSections = [ + "Ready:", + "Waiting:", + "Available but not searched:", + "Indexed alternatives:", + ]; + for (const section of forbiddenSections) { + assert( + !lines.some((line) => line.startsWith(section)), + `${context}: legacy flat section ${section}`, + ); + } + assert( + !lines.some((line) => line === "Evidence may change."), + `${context}: vague evidence policy prose`, + ); + assert( + !lines.some((line) => line.startsWith("Do not repeat")), + `${context}: repeat policy prose`, + ); + assert( + !lines.some((line) => line.startsWith("Do not poll")), + `${context}: poll policy prose`, + ); + + const hasReadinessText = lines.some((line) => + /^ {2}(?:Indexing|Searched|Ready now|Unavailable):/.test(line), + ); + if (hasReadinessText) { + assert( + lines.some((line) => /^-\s+\S/.test(line)), + `${context}: readiness details must be grouped under a target`, + ); + } - const searchRefOccurrences = text.match(/search_ref=/g)?.length ?? 0; + const nextLines = lines.filter((line) => line.startsWith("Next:")); + assert( + nextLines.length <= 1, + `${context}: multiple Next actions are not allowed`, + ); + + const searchRefOccurrences = formatterText.match(/search_ref=/g)?.length ?? 0; assert( searchRefOccurrences <= 1, `${context}: search_ref= must appear at most once`, ); if (searchRefOccurrences === 1) { - const refLine = lines.find((line) => line.includes("search_ref=")); + const refLine = formatterLines.find((line) => line.includes("search_ref=")); assert( refLine?.trimStart().startsWith("Next:"), `${context}: search_ref= must appear only on a Next line`, ); + assert( + refLine?.startsWith("Next: search_status "), + `${context}: search_ref= must use the MCP search_status action`, + ); + assert( + refLine !== undefined, + `${context}: search_ref= must appear only on a Next line`, + ); + const match = refLine.match(/search_ref=(?:"([^"]+)"|(\S+))/); + const searchRef = match?.[1] ?? match?.[2]; + const summaryLines = lines.filter((line) => + /^Search\s+\S+\s+\|/.test(line), + ); + assert( + summaryLines.length === 1, + `${context}: expected one Search session summary`, + ); + assert( + searchRef !== undefined && + summaryLines[0]?.startsWith(`Search ${searchRef} |`), + `${context}: session summary does not match search_ref action`, + ); } assert( - text.includes("code_read") || - text.includes("docs_read") || - text.includes("search_status"), + hasHitLocator(lines, isMcpCodeReadLocator) || + hasHitLocator(lines, isMcpDocsReadLocator) || + lines.some((line) => line.startsWith("Next:")), `${context}: missing ready-to-call result or status follow-up`, ); } +function hasHitLocator( + lines: string[], + isLocator: (line: string) => boolean, +): boolean { + return lines.some( + (line, index) => + /^\[\d+\]\s/.test(line) && isLocator(lines[index + 1] ?? ""), + ); +} + +function hasNonEmptyMcpArgument(line: string, argument: string): boolean { + return new RegExp( + `(?:^|\\s)${argument}=(?:"[^"]+"|'[^']+'|[^\\s"']+)(?=\\s|$)`, + ).test(line); +} + +function isMcpCodeReadLocator(line: string): boolean { + return ( + /^ {4}code_read\b/.test(line) && + hasNonEmptyMcpArgument(line, "target") && + hasNonEmptyMcpArgument(line, "path") + ); +} + +function isMcpDocsReadLocator(line: string): boolean { + return ( + /^ {4}docs_read\b/.test(line) && hasNonEmptyMcpArgument(line, "page_id") + ); +} + export function assertJsonResult( result: McpSmokeToolResult, context: string, diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index 333ad7c9..104e39a4 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -124,7 +124,9 @@ describe("searchStatusTool", () => { }); const text = await tool.handler({ search_ref: incomplete.searchRef }, {}); - expect(text.content[0]?.text).toContain("Evidence: provisional snapshot"); + expect(text.content[0]?.text).toContain( + "Indexing: provisional snapshot is searchable", + ); expect(text.content[0]?.text).toContain( 'Next: search_status search_ref="search-ref-provisional" wait_timeout_ms=20000', ); @@ -268,7 +270,9 @@ describe("searchStatusTool", () => { ); const text = await tool.handler({ search_ref: "search-ref-docs" }, {}); - expect(text.content[0]?.text).toContain("Waiting: site docs"); + expect(text.content[0]?.text).toContain( + "Indexing: expressjs.com/en/guide docs", + ); expect(text.content[0]?.text).toContain("Searched: repository docs"); }); @@ -350,7 +354,6 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("TIMEOUT - no result snapshot returned"); expect(text).not.toContain("search_status |"); - expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); }); @@ -367,7 +370,6 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-failed" }, {}); const text = result.content[0]?.text ?? ""; expect(text).toContain("FAILED - no result snapshot returned"); - expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); }); @@ -411,8 +413,6 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-deferred" }, {}); const text = textResult.content[0]?.text ?? ""; expect(text).toContain("DEFERRED - 1 result returned"); - expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -472,8 +472,6 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-future" }, {}); const text = textResult.content[0]?.text ?? ""; expect(text).toContain("FUTURE_SESSION_STATE - 1 result returned"); - expect(text).toContain("Evidence may change."); - expect(text).toContain("Do not poll this session again."); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -553,12 +551,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: incomplete.searchRef }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain( - "Indexing site:example.com - no results returned yet", - ); - expect(text).toContain("Searched: site docs (site:example.com)"); - expect(text).toContain("Suggested site targets: site:docs.example.com"); - expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain("Indexing - no results yet"); + expect(text).toContain("- site:example.com"); + expect(text).toContain("Searched: site:example.com docs"); + expect(text).toContain("Suggested sites: site:docs.example.com"); + expect(text).toContain("More suggested sites omitted"); expect(text).toContain( 'Next: search_status search_ref="ref-site-recovery" wait_timeout_ms=20000', ); @@ -610,12 +607,9 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-stale" }, {}); const text = result.content[0]?.text ?? ""; - const warning = - "requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes."; - expect(text).toContain("Target: requested npm:express latest"); - expect(text).toContain(`Evidence: ${warning}`); - expect(text).toContain(warning); - expect(text.split(warning)).toHaveLength(2); + expect(text).toContain("- npm:express latest -> 5.2.1"); + expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text.match(/5\.1\.0 while 5\.2\.1 indexes/g)).toHaveLength(1); }); it("renders source targetResolution notes in completed text", async () => { @@ -660,10 +654,10 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain( - "Evidence: served older snapshot npm:express@4.18.2.", - ); - expect(text).toContain("Indexed alternatives: versions 4.18.2"); + expect(text).toContain("- npm:express@4.18.2"); + expect(text).toContain("Using: 4.18.2 (older snapshot)"); + expect(text).toContain("Ready now: versions"); + expect(text).toContain("4.18.2"); expect(text).not.toContain("ref_resolution_deferred"); }); @@ -703,10 +697,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("No results returned from site:example.com"); - expect(text).toContain("Searched: site docs (site:example.com)"); - expect(text).toContain("Suggested site targets: site:example.com/docs"); - expect(text).toContain("Additional site targets were omitted."); + expect(text).toContain("No results returned"); + expect(text).toContain("- site:example.com"); + expect(text).toContain("Searched: site:example.com docs"); + expect(text).toContain("Suggested sites: site:example.com/docs"); + expect(text).toContain("More suggested sites omitted"); expect(text).toContain("Next: retry one suggested site target explicitly."); expect(text).not.toContain("Next: shorten or broaden site query."); }); @@ -758,9 +753,8 @@ describe("searchStatusTool", () => { {}, ); const text = result.content[0]?.text ?? ""; - expect(text).toContain( - "No results returned from github:githits-com/no-such-repo", - ); + expect(text).toContain("No results returned"); + expect(text).toContain("- github:githits-com/no-such-repo"); expect(text).toContain("Unavailable: code"); expect(text).not.toContain("Searched: code"); expect(text).not.toContain("Repository ref cannot be resolved"); @@ -780,9 +774,8 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(result.isError).toBeUndefined(); - expect(text).toContain("Searching - no result snapshot returned yet"); - expect(text).toContain("Ready: 0/1 targets"); - expect(text).toContain("Do not repeat search."); + expect(text).toContain("Searching - no result snapshot yet"); + expect(text).toContain("Search ref-text | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="ref-text" wait_timeout_ms=20000', ); @@ -813,11 +806,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-alternatives" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("Indexed alternatives: versions 4.18.2; refs main"); + expect(text).toContain("- npm:express latest"); + expect(text).toContain("Ready now: versions 4.18.2, refs main"); expect(text).toContain( 'Next: search_status search_ref="ref-alternatives" wait_timeout_ms=20000', ); - expect(text).toContain("Do not repeat search."); expect(text).not.toContain("allow_partial_results: true"); }); }); diff --git a/packages/mcp/src/tools/search.test.ts b/packages/mcp/src/tools/search.test.ts index bc0cfe01..8ec99ba4 100644 --- a/packages/mcp/src/tools/search.test.ts +++ b/packages/mcp/src/tools/search.test.ts @@ -148,7 +148,9 @@ describe("searchTool", () => { }, {}, ); - expect(text.content[0]?.text).toContain("Waiting: site docs"); + expect(text.content[0]?.text).toContain( + "Indexing: expressjs.com/en/guide docs", + ); expect(text.content[0]?.text).toContain("Searched: repository docs"); }); diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 944e3455..0ab60f14 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -366,6 +366,8 @@ function assertTerminalOutput(result: CommandResult, context: string): string { export function assertSearchTerminalText(text: string, context: string): void { const lines = text.split("\n"); + const formatterLines = lines.filter((line) => !line.startsWith(" ")); + const formatterText = formatterLines.join("\n"); const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); assert( @@ -375,38 +377,158 @@ export function assertSearchTerminalText(text: string, context: string): void { `${context}: non-outcome text precedes search outcome`, ); assert( - !lines.some((line) => /^status\s*:/i.test(line.trim())), + /^(?:Preparing|Indexing|Searching)\b|^No results returned\b|^\d+ results?\b|^[A-Z_]+ - /.test( + firstLine, + ), + `${context}: missing outcome headline`, + ); + assert( + !formatterLines.some((line) => /^status\s*:/i.test(line.trim())), `${context}: duplicated lifecycle status line`, ); - assert(!text.includes("searchRef:"), `${context}: leaked searchRef detail`); - assert(!text.includes("indexingRef"), `${context}: leaked indexingRef`); + const lifecycleOutcomeLines = formatterLines.filter((line) => + /^(?:Preparing|Indexing|Searching)\b/.test(line), + ); + assert( + lifecycleOutcomeLines.length <= 1, + `${context}: duplicate lifecycle outcome lines`, + ); assert( - !text.includes("freshnessReason"), + !formatterText.includes("searchRef:") && + !formatterText.includes("searchRef="), + `${context}: leaked searchRef detail`, + ); + assert( + !formatterText.includes("indexingRef"), + `${context}: leaked indexingRef`, + ); + assert( + !formatterText.includes("freshnessReason"), `${context}: leaked freshnessReason`, ); - const statusActions = lines.filter((line) => + const forbiddenSections = [ + "Ready:", + "Waiting:", + "Available but not searched:", + "Indexed alternatives:", + ]; + for (const section of forbiddenSections) { + assert( + !formatterLines.some((line) => line.startsWith(section)), + `${context}: legacy flat section ${section}`, + ); + } + assert( + !formatterLines.some((line) => line === "Evidence may change."), + `${context}: vague evidence policy prose`, + ); + assert( + !formatterLines.some((line) => line.startsWith("Do not repeat")), + `${context}: repeat policy prose`, + ); + assert( + !formatterLines.some((line) => line.startsWith("Do not poll")), + `${context}: poll policy prose`, + ); + + const hasReadinessText = formatterLines.some((line) => + /^ {2}(?:Indexing|Searched|Ready now|Unavailable):/.test(line), + ); + if (hasReadinessText) { + assert( + formatterLines.some((line) => /^-\s+\S/.test(line)), + `${context}: readiness details must be grouped under a target`, + ); + } + + const nextLines = formatterLines.filter((line) => line.startsWith("Next:")); + assert( + nextLines.length <= 1, + `${context}: multiple Next actions are not allowed`, + ); + const statusActions = nextLines.filter((line) => line.startsWith("Next: githits search-status "), ); assert( statusActions.length <= 1, `${context}: expected at most one search-status action`, ); - const paginationLines = lines.filter((line) => + const paginationLines = formatterLines.filter((line) => line.startsWith("More hits available."), ); assert( !paginationLines.some((line) => /\b(?:offset|limit)=/.test(line)), `${context}: MCP pagination syntax leaked into CLI output`, ); + const summaryLines = formatterLines.filter((line) => + /^Search\s+\S+\s+\|/.test(line), + ); assert( - text.includes("githits code read") || - text.includes("githits docs read") || - lines.some((line) => line.startsWith("Next: ")), + summaryLines.length <= 1, + `${context}: expected at most one Search session summary`, + ); + if (statusActions.length > 0) { + assert( + summaryLines.length === 1, + `${context}: expected one Search session summary`, + ); + } + if (statusActions.length === 1) { + const searchRef = statusActions[0]?.match( + /^Next: githits search-status (\S+) /, + )?.[1]; + assert( + searchRef !== undefined && + summaryLines[0]?.startsWith(`Search ${searchRef} |`), + `${context}: session summary does not match search-status action`, + ); + } + assert( + !formatterText.includes("search_ref="), + `${context}: MCP search_ref syntax leaked into CLI output`, + ); + assert( + hasSearchHitLocator(lines, isCliCodeReadLocator) || + hasSearchHitLocator(lines, isCliDocsReadLocator) || + nextLines.length > 0, `${context}: missing result follow-up or next action`, ); } +function hasSearchHitLocator( + lines: string[], + isLocator: (line: string) => boolean, +): boolean { + return lines.some( + (line, index) => + /^\[\d+\]\s/.test(line) && isLocator(lines[index + 1] ?? ""), + ); +} + +const CLI_SHELL_ARGUMENT = String.raw`(?:'[^']+'|"[^"]+"|[^\s'"]+)`; +const CLI_POSITIONAL_ARGUMENT = String.raw`(?:'[^']+'|"[^"]+"|(?!-)[^\s'"]+)`; +const CLI_PACKAGE_CODE_READ_LOCATOR = new RegExp( + String.raw`^ {4}githits code read\s+(?!--repo-url\b)${CLI_POSITIONAL_ARGUMENT}\s+${CLI_POSITIONAL_ARGUMENT}(?:\s|$)`, +); +const CLI_REPOSITORY_CODE_READ_LOCATOR = new RegExp( + String.raw`^ {4}githits code read\s+--repo-url\s+${CLI_SHELL_ARGUMENT}(?:\s+--git-ref\s+${CLI_SHELL_ARGUMENT})?\s+${CLI_POSITIONAL_ARGUMENT}(?:\s|$)`, +); +const CLI_DOCS_READ_LOCATOR = new RegExp( + String.raw`^ {4}githits docs read\s+${CLI_POSITIONAL_ARGUMENT}(?:\s|$)`, +); + +function isCliCodeReadLocator(line: string): boolean { + return ( + CLI_PACKAGE_CODE_READ_LOCATOR.test(line) || + CLI_REPOSITORY_CODE_READ_LOCATOR.test(line) + ); +} + +function isCliDocsReadLocator(line: string): boolean { + return CLI_DOCS_READ_LOCATOR.test(line); +} + function assertJsonOutput(result: CommandResult, context: string): unknown { assert( result.exitCode === 0, diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 18aafaec..023aef87 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -25,11 +25,35 @@ import { import { toStdioLaunch } from "./smoke-launch-target.ts"; describe("CLI search smoke contract", () => { - const valid = `1 result from npm:express@5.2.1 -githits code read 'npm:express@5.2.1' 'lib/application.js' --lines 1-10 + const valid = `Indexing - no results yet + +- npm:n8n -> 2.36.7 + Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master + +Search smoke-ref | 0/1 target ready +Next: githits search-status smoke-ref --wait 20`; + const completedWithTargetReadiness = `No results returned from npm:express + +- npm:express@4.18.2 + Searched: repository docs + +Next: shorten or broaden query; use githits code grep.`; + const completed = `1 result + +[1] npm:express@5.2.1 code + githits code read 'npm:express@5.2.1' 'lib/application.js' --lines 1-10 More hits available. Pass --offset 10 or --limit N to widen.`; + const completedDocs = `1 result + +[1] docs.example.com/getting-started docs + githits docs read 'docs.example.com/getting-started' --lines 1-10`; it("accepts outcome-first text with CLI-native actions", () => { + expect(valid.split("\n")[0]).toBe("Indexing - no results yet"); + expect(valid).toContain("- npm:n8n -> 2.36.7"); + expect(valid).toContain(" Indexing: code, repository docs | Ready now:"); + expect(valid).toContain("Search smoke-ref | 0/1 target ready"); + expect(valid).toContain("Next: githits search-status smoke-ref --wait 20"); expect(() => assertSearchTerminalText(valid, "search")).not.toThrow(); expect(() => assertSearchTerminalText( @@ -47,12 +71,149 @@ More hits available. Pass --offset 10 or --limit N to widen.`; it.each([ [`Warning: indexing\n${valid}`, "non-outcome text"], - [`${valid}\nstatus: indexing`, "lifecycle status"], - [valid.replace("--offset 10", "offset=10"), "MCP pagination syntax"], + [`${completed}\nstatus: indexing`, "lifecycle status"], + [completed.replace("--offset 10", "offset=10"), "MCP pagination syntax"], ["1 result from npm:express@5.2.1", "missing result follow-up"], ])("rejects invalid search text", (text, message) => { expect(() => assertSearchTerminalText(text, "search")).toThrow(message); }); + + it("accepts completed hit text without a target group", () => { + expect(() => assertSearchTerminalText(completed, "search")).not.toThrow(); + }); + + it("accepts completed documentation hit text without a target group", () => { + expect(() => + assertSearchTerminalText(completedDocs, "search"), + ).not.toThrow(); + }); + + it.each([ + [ + "1 result\n\n[1] npm:express@5.2.1 code\n This payload mentions githits code read but has no locator", + ], + [ + "1 result\n\n[1] npm:express@5.2.1 code\n githits code read 'npm:express@5.2.1' --lines 1-10", + ], + [ + "1 result\n\n[1] npm:express@5.2.1 code\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", + ], + [ + "1 result\n\n[1] docs.example.com docs\n githits docs read --lines 1-10", + ], + ])("rejects incomplete or prose-only hit follow-ups", (text) => { + expect(() => assertSearchTerminalText(text, "search")).toThrow( + "missing result follow-up or next action", + ); + }); + + it("accepts completed target readiness without a search session", () => { + expect(() => + assertSearchTerminalText(completedWithTargetReadiness, "search"), + ).not.toThrow(); + }); + + it("rejects duplicate search session summaries", () => { + expect(() => + assertSearchTerminalText( + `${valid}\nSearch another-ref | 0/1 target ready`, + "search", + ), + ).toThrow("expected at most one Search session summary"); + }); + + it.each([ + ["Ready:", "legacy flat section Ready:"], + ["Waiting:", "legacy flat section Waiting:"], + [ + "Available but not searched:", + "legacy flat section Available but not searched:", + ], + ["Indexed alternatives:", "legacy flat section Indexed alternatives:"], + ["Evidence may change.", "vague evidence policy prose"], + ["Do not repeat search.", "repeat policy prose"], + ["Do not poll this session.", "poll policy prose"], + ])("rejects superseded top-level search text %s", (line, message) => { + expect(() => + assertSearchTerminalText(`${valid}\n${line}`, "search"), + ).toThrow(message); + }); + + it("rejects duplicate lifecycle, status, and Next lines", () => { + expect(() => + assertSearchTerminalText(`${valid}\nIndexing - no results yet`, "search"), + ).toThrow("duplicate lifecycle outcome lines"); + expect(() => + assertSearchTerminalText(`${valid}\nstatus: indexing`, "search"), + ).toThrow("duplicated lifecycle status line"); + expect(() => + assertSearchTerminalText( + `${valid}\nNext: githits search-status other --wait 20`, + "search", + ), + ).toThrow("multiple Next actions"); + }); + + it("rejects target diagnostics and missing target grouping", () => { + expect(() => + assertSearchTerminalText(`${valid}\nsearchRef=leaked`, "search"), + ).toThrow("leaked searchRef detail"); + expect(() => + assertSearchTerminalText(`${valid}\nindexingRef=leaked`, "search"), + ).toThrow("leaked indexingRef"); + expect(() => + assertSearchTerminalText( + valid.replace( + " Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", + " Ready now: versions 2.36.7", + ), + "search", + ), + ).not.toThrow(); + expect(() => + assertSearchTerminalText( + valid.replace("- npm:n8n -> 2.36.7\n", ""), + "search", + ), + ).toThrow("readiness details must be grouped under a target"); + }); + + it.each([ + ["status: payload", "duplicated lifecycle status line"], + ["searchRef=payload", "leaked searchRef detail"], + ["indexingRef payload", "leaked indexingRef"], + ["search_ref=payload", "MCP search_ref syntax leaked into CLI output"], + ])("rejects target-detail diagnostic %s", (diagnostic, message) => { + const readinessLine = + " Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master"; + const targetDetail = valid.replace(readinessLine, ` ${diagnostic}`); + + expect(() => assertSearchTerminalText(targetDetail, "search")).toThrow( + message, + ); + }); + + it("ignores formatter-like words and diagnostics in indented hit content", () => { + const hitText = `1 result + +[1] npm:express@5.2.1 code + githits code read 'npm:express@5.2.1' 'lib/application.js' --lines 1-10 + Ready: payload text + Waiting: payload text + Available but not searched: payload text + Indexed alternatives: payload text + Evidence may change. + Do not repeat this payload. + Do not poll this payload. + Next: payload text + Indexing: payload text + status: payload text + searchRef=payload text + indexingRef payload text + search_ref=payload text`; + + expect(() => assertSearchTerminalText(hitText, "search")).not.toThrow(); + }); }); describe("smoke script options", () => { diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index b71a78ee..7755ba01 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -442,10 +442,15 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output.split("\n")[0]).toBe("No results returned"); expect(output).toContain( - "Suggested site targets: site:example.com/docs, site:example.com/guide", + "- site:example.com\n Searched: site:example.com docs | Suggested sites: site:example.com/docs,\n site:example.com/guide", ); expect(output).not.toContain("Additional site targets were omitted."); + expect(output).toContain("Search search-ref-123 | completed"); + expect(output).toContain( + "Next: retry one suggested site target explicitly.", + ); consoleSpy.mockRestore(); }); @@ -516,23 +521,23 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "No results returned from npm:express@5.1.0", - ); - expect(output).toContain( - "Searched: repository docs (https://github.com/expressjs/express @", - ); - expect(output).toContain( - "Available but not searched: expressjs.com/en/guide docs (120 pages; partial)", + expect(output.split("\n")[0]).toBe("No results returned"); + expect(output).toContain("- npm:express@5.1.0"); + expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch( + /Ready now: expressjs\.com\/en\/guide docs \(not searched;\s+120 pages; partial\)/, ); expect(output).not.toContain("Documentation sources:"); expect(output).not.toContain("Documentation corpora"); expect(output).not.toContain("indexing is still in progress"); - expect(output).toContain("Do not repeat immediately."); + expect(output).not.toContain("Do not repeat"); expect(output).not.toContain("Try a shorter or broader query"); expect(output).not.toContain("Run again with a larger --wait"); - expect(output).toContain("Evidence may change."); - expect(output).toContain("githits search-status search-ref-docs"); + expect(output).not.toContain("Evidence may change."); + expect(output).toContain("Search search-ref-docs | completed"); + expect(output).toContain( + "Next: githits search-status search-ref-docs --wait 20", + ); consoleSpy.mockRestore(); }); @@ -557,12 +562,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "No results returned from npm:express@5.1.0", - ); - expect(output).toContain("Searched: repository docs"); - expect(output).toContain("Available but not searched:"); - expect(output).toContain("Do not repeat immediately."); + expect(output.split("\n")[0]).toBe("No results returned"); + expect(output).toContain("- npm:express@5.1.0"); + expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toContain("Ready now: expressjs.com/en/guide docs"); + expect(output).not.toContain("Do not repeat"); consoleSpy.mockRestore(); }); @@ -601,7 +605,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Waiting: code"); + expect(output).toContain("Indexing: code"); + expect(output).toContain("- npm:express@5.1.0"); expect(output).toContain("Searched: repository docs"); expect(output).toContain("Next: rerun search later."); consoleSpy.mockRestore(); @@ -703,9 +708,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("1 result from npm:express@5.1.0"); - expect(output).toContain("Searched: repository docs"); - expect(output).toContain("site docs"); + expect(output.split("\n")[0]).toBe("1 result"); + expect(output).toContain("- npm:express@5.1.0"); + expect(output).toContain( + "Searched: repository docs, expressjs.com/en/guide docs", + ); expect(output).toContain("[1] express/routing npm:express docs"); expect(output).toContain("githits docs read 'express/routing'"); expect(output).not.toContain("Documentation sources"); @@ -966,10 +973,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Indexing - no result snapshot returned yet", - ); - expect(output).toContain("Ready: 0/1 targets"); + expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output).toContain("Search search-ref-123 | 0/1 target ready"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -985,6 +990,31 @@ describe("searchAction", () => { if (!source) throw new Error("expected source fixture"); const outcome: UnifiedSearchIncomplete = { ...createIncompleteOutcome("INDEXING", "n8n-search-ref"), + progress: { + searchRef: "n8n-search-ref", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 0, + elapsedMs: 8200, + query: "human review approval node output", + queryWarnings: [], + sources: ["CODE"], + targets: [ + { + requested: "npm:n8n", + resolvedRequested: "npm:n8n@2.36.7", + freshness: "INDEXING", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + { version: "2.22.6", ref: "v2.22.6" }, + { version: "2.21.7", ref: "v2.21.7" }, + ], + availableRefs: [{ ref: "HEAD" }, { ref: "master" }], + }, + ], + }, result: { ...defaultUnifiedSearchOutcome.result, query: "human review approval node output", @@ -1001,6 +1031,40 @@ describe("searchAction", () => { indexingStatus: "INDEXING", codeIndexState: "PENDING", }, + { + ...source, + source: "DOCS", + targetLabel: "npm:n8n@2.36.7", + resultCount: 0, + targetResolution: { + freshness: "indexing", + freshnessReason: "latest_version_indexing", + indexingRef: "indexing-ref-hidden", + availableVersions: [ + { version: "2.26.9", ref: "v2.26.9" }, + { version: "2.26.5", ref: "v2.26.5" }, + { version: "2.23.2", ref: "v2.23.2" }, + { version: "2.22.6", ref: "v2.22.6" }, + ], + availableRefs: [{ ref: "HEAD" }, { ref: "master" }], + }, + contributors: [ + { + kind: "DOCPACK", + state: "READY", + resultCount: 0, + siteKey: "n8n.io", + siteUrl: "https://n8n.io", + coverage: { coverageState: "CAPPED", pagesCrawled: 1480 }, + }, + { + kind: "REPOSITORY_DOCS", + state: "PENDING", + resultCount: 0, + repositoryUrl: "https://github.com/n8n-io/n8n", + }, + ], + }, ], }, }; @@ -1017,19 +1081,28 @@ describe("searchAction", () => { deps, ); const initial = String(consoleSpy.mock.calls[0]?.[0]); - expect(initial.split("\n")[0]).toBe( - "Indexing npm:n8n@2.36.7 - no results returned yet", - ); - expect(initial).toContain("Ready: 0/1 targets"); - expect(initial).toContain( - "Next: githits search-status n8n-search-ref --wait 20", + expect(initial).toBe( + [ + "Indexing - no results yet", + "", + "- npm:n8n -> 2.36.7", + " Indexing: code, repository docs | Ready now: n8n.io docs (not searched;", + " 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD,", + " master", + "", + "Search n8n-search-ref | 0/1 target ready", + "Next: githits search-status n8n-search-ref --wait 20", + ].join("\n"), ); expect(initial.match(/^Indexing\b/gm)).toHaveLength(1); - expect(initial.match(/^Ready:/gm)).toHaveLength(1); + expect(initial.match(/^Search /gm)).toHaveLength(1); expect(initial.match(/^Next:/gm)).toHaveLength(1); - expect(initial.match(/n8n-search-ref/g)).toHaveLength(1); - expect(initial.split("\n").length).toBeLessThanOrEqual(7); + expect(initial.match(/n8n-search-ref/g)).toHaveLength(2); expect(initial).not.toContain("search_status search_ref="); + expect(initial).not.toContain("Warning:"); + expect(initial).not.toContain("Evidence may change"); + expect(initial).not.toContain("Do not repeat"); + expect(initial).not.toContain("Do not poll"); await searchStatusAction("n8n-search-ref", {}, deps); const status = String(consoleSpy.mock.calls[1]?.[0]); @@ -1053,19 +1126,18 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Indexing npm:express@5.1.0 - no results returned yet", + expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output).toContain("- npm:express@5.1.0"); + expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch( + /Ready now: expressjs\.com\/en\/guide docs \(not searched;\s+120 pages; partial\)/, ); - expect(output).toContain("Searched: repository docs"); - expect(output).toContain( - "Available but not searched: expressjs.com/en/guide docs (120 pages; partial)", - ); - expect(output.match(/Evidence may change\./g)).toHaveLength(1); + expect(output).not.toContain("Evidence may change."); expect(output).toContain("githits search-status search-ref-docs"); consoleSpy.mockRestore(); }); - it("renders terminal deferred initial evidence without polling it", async () => { + it("renders terminal deferred initial evidence with a positive recovery action", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); const incomplete = createIncompleteOutcome("DEFERRED", "ref-deferred"); incomplete.result = { @@ -1085,8 +1157,9 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain("[1] npm:express@4.18.2 code"); - expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Search ref-deferred | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); @@ -1096,7 +1169,7 @@ describe("searchAction", () => { consoleSpy.mockRestore(); }); - it("preserves initial evidence for an unrecognized status without polling it", async () => { + it("preserves initial evidence for an unrecognized status with a positive recovery action", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); const incomplete = createIncompleteOutcome( "FUTURE_SESSION_STATE", @@ -1121,8 +1194,9 @@ describe("searchAction", () => { expect(output.split("\n")[0]).toBe( "FUTURE_SESSION_STATE - 1 result returned", ); + expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain("[1] npm:express@4.18.2 code"); - expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); @@ -1170,15 +1244,18 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Indexing site:example.com - no results returned yet", - ); - expect(output).toContain("Waiting: site docs"); + expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output).toContain("- site:example.com"); + expect(output).toContain("Indexing: site:example.com docs"); expect(output).toContain( "Incompatible filter (site:example.com): language", ); - expect(output).toContain("Suggested site targets: site:docs.example.com"); - expect(output).toContain("Additional site targets were omitted."); + expect(output).toContain("Suggested sites: site:docs.example.com"); + expect(output).toContain("More suggested sites omitted"); + expect(output).toContain("Search search-ref-site | 0/1 target ready"); + expect(output).toContain( + "Next: githits search-status search-ref-site --wait 20", + ); consoleSpy.mockRestore(); }); @@ -1318,9 +1395,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Evidence: requested npm:express latest; served older snapshot npm:express@5.1.0 while npm:express@5.2.1 indexes.", - ); + expect(output).toContain("- npm:express latest -> 5.2.1"); + expect(output).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(output).not.toContain("Evidence:"); consoleSpy.mockRestore(); }); @@ -1437,9 +1514,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("1 result"); expect(output).toContain( - "Evidence: provisional snapshot; indexing continues.", + "- npm:express@4.18.2\n Indexing: provisional snapshot is searchable | Searched: code", ); - expect(output).toContain("Evidence may change."); + expect(output).not.toContain("Evidence may change."); + expect(output).not.toContain("Evidence:"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -1497,10 +1575,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Evidence: served older snapshot github:expressjs/express#refs/heads/master.", - ); - expect(output).toContain("Indexed alternatives: refs master"); + expect(output).toContain("- github:expressjs/express#refs/heads/master"); + expect(output).toContain("Using: refs/heads/master (older snapshot)"); + expect(output).toMatch(/Ready now:\s+refs master/); + expect(output).not.toContain("Evidence:"); + expect(output).not.toContain("Indexed alternatives:"); expect(output).not.toContain("Next: githits search-status"); consoleSpy.mockRestore(); }); @@ -1903,10 +1982,8 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Searching - no result snapshot returned yet", - ); - expect(output).toContain("Ready: 1/1 targets"); + expect(output.split("\n")[0]).toBe("Searching - no result snapshot yet"); + expect(output).toContain("Search search-ref-123 | 1/1 target ready"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -1940,11 +2017,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Indexing site:example.com/old - no result snapshot returned yet", - ); + expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output).toContain("- site:example.com"); + expect(output).toContain("Search search-ref-stale | 0/1 target ready"); expect(output).toContain( - "Target: requested site:example.com; fresh site:example.com; served site:example.com/old", + "Next: githits search-status search-ref-stale --wait 20", ); consoleSpy.mockRestore(); }); @@ -1995,13 +2072,14 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Indexing site:example.com/old - no results returned yet", - ); + expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output).toContain("- site:example.com"); + expect(output).toMatch(/Searched:\s+site:example.com docs/); + expect(output).toContain("Suggested sites: site:docs.example.com"); + expect(output).toContain("Search search-ref-site | 0/1 target ready"); expect(output).toContain( - "Target: requested site:example.com; fresh site:example.com; served site:example.com/old", + "Next: githits search-status search-ref-site --wait 20", ); - expect(output).toContain("Suggested site targets: site:docs.example.com"); consoleSpy.mockRestore(); }); @@ -2089,13 +2167,15 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "Indexing github:expressjs/express#master - no result snapshot returned yet", + expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output).toContain( + "- github:expressjs/express#refs/heads/master -> master", ); + expect(output).toContain("Indexing | Ready now: refs master"); + expect(output).toContain("Search search-ref-123 | 0/1 target ready"); expect(output).toContain( - "Target: requested github:expressjs/express#refs/heads/master; fresh github:expressjs/express#master; served github:expressjs/express#master", + "Next: githits search-status search-ref-123 --wait 20", ); - expect(output).toContain("Indexed alternatives: refs master"); consoleSpy.mockRestore(); }); @@ -2120,7 +2200,7 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("TIMEOUT - no result snapshot returned"); - expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Search search-ref-timeout | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("longer wait"); expect(output).not.toContain("Search still in progress."); @@ -2178,7 +2258,8 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); expect(output).toContain("[1] npm:express@4.18.2 code"); - expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Search ref-deferred | 1/2 targets ready"); + expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); @@ -2214,7 +2295,8 @@ describe("searchStatusAction", () => { "FUTURE_SESSION_STATE - 1 result returned", ); expect(output).toContain("[1] npm:express@4.18.2 code"); - expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Search ref-future | 0/1 target ready"); + expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); @@ -2243,7 +2325,8 @@ describe("searchStatusAction", () => { expect(output.split("\n")[0]).toBe( "DEFERRED - no result snapshot returned", ); - expect(output).toContain("Do not poll this session again."); + expect(output).toContain("Search ref-deferred-empty | 0/1 target ready"); + expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); expect(output).not.toContain("githits search-status"); @@ -2341,14 +2424,13 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "No results returned from npm:express@5.1.0", + expect(output.split("\n")[0]).toBe("No results returned"); + expect(output).toContain("- npm:express@5.1.0"); + expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch( + /Ready now: expressjs\.com\/en\/guide docs \(not searched;\s+120 pages; partial\)/, ); - expect(output).toContain("Searched: repository docs"); - expect(output).toContain( - "Available but not searched: expressjs.com/en/guide docs (120 pages; partial)", - ); - expect(output.match(/Evidence may change\./g)).toHaveLength(1); + expect(output).not.toContain("Evidence may change."); expect( output.match(/githits search-status search-ref-docs --wait 20/g), ).toHaveLength(1); @@ -2382,13 +2464,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe( - "No results returned from npm:express@5.1.0", - ); - expect(output).toContain("Searched: repository docs"); - expect(output).toContain( - "Unavailable: site docs (https://expressjs.com/en/guide)", - ); + expect(output.split("\n")[0]).toBe("No results returned"); + expect(output).toContain("- npm:express@5.1.0"); + expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toContain("Unavailable: expressjs.com/en/guide docs"); consoleSpy.mockRestore(); }); @@ -2479,8 +2558,13 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Suggested site targets: site:example.com/docs"); - expect(output).toContain("Additional site targets were omitted."); + expect(output).toContain("- site:example.com"); + expect(output).toContain("Suggested sites: site:example.com/docs"); + expect(output).toContain("More suggested sites omitted"); + expect(output).toContain("Search search-ref-123 | completed"); + expect(output).toContain( + "Next: retry one suggested site target explicitly.", + ); consoleSpy.mockRestore(); }); From a687e8c4d71393bfc07c39d16b40d48a0d565cdf Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 00:51:16 +0300 Subject: [PATCH 33/46] fix: preserve search target boundaries Keep requested targets distinct across shared aliases, attach readiness by exact requested identity, and always render a positive action for limited empty results. Normalize freshness in presentation and tighten CLI/MCP smoke symmetry. --- docs/implementation/cli-commands.md | 2 +- .../unified-search-presentation.test.ts | 98 ++++++++- .../src/shared/unified-search-presentation.ts | 205 +++++++++++++++--- .../src/shared/unified-search-text.test.ts | 50 ++++- .../mcp/src/shared/unified-search-text.ts | 23 +- packages/mcp/src/smoke-test.test.ts | 40 ++++ packages/mcp/src/smoke-test.ts | 12 +- scripts/cli-smoke.ts | 4 +- scripts/smoke-scripts.test.ts | 12 + src/commands/search.test.ts | 2 +- 10 files changed, 401 insertions(+), 47 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index ec58c5d1..c412f305 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -252,7 +252,7 @@ Search | 0/1 target ready Next: githits search-status --wait 20 ``` -**Highlighting.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, and optional evidence or alternatives are dim. Color never carries meaning and does not change wording or wrapping. +**Highlighting.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, target details remain plain, and the optional session row is dim. Color never carries meaning and does not change wording or wrapping. **Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 78c88e7c..1f6c2f17 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { + classifyTargetFreshness, projectUnifiedSearchPresentation, targetDisplayFamilyKey, } from "./unified-search-presentation.js"; @@ -295,7 +296,7 @@ describe("projectUnifiedSearchPresentation", () => { ], }, ]); - expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.action).toEqual({ kind: "new_search" }); }, ); @@ -834,11 +835,13 @@ describe("projectUnifiedSearchPresentation", () => { }); expect(presentation.targetGroups).toEqual([ { + freshnessKind: "indexing", identity: { requested: "npm:n8n", fresh: "npm:n8n@2.36.7", freshness: "INDEXING", }, + inProgress: true, sources: [ { kind: "code", @@ -1155,6 +1158,97 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); + it("keeps distinct requested targets separate when they share a served snapshot", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 2, + elapsedMs: 200, + targets: [ + { + requested: "npm:express@5.1.0", + resolvedRequested: "npm:express@5.1.0", + served: "npm:express@5.1.0", + availableVersions: [{ version: "5.0.0", ref: "v5.0.0" }], + }, + { + requested: "npm:express", + resolvedRequested: "npm:express@5.2.1", + served: "npm:express@5.1.0", + freshness: "INDEXING", + availableVersions: [{ version: "5.2.0", ref: "v5.2.0" }], + }, + ], + }, + sourceStatus: [ + source({ + targetLabel: "npm:express@5.1.0", + requestedTarget: "npm:express@5.1.0", + freshTarget: "npm:express@5.1.0", + servedTarget: "npm:express@5.1.0", + codeIndexState: "CURRENT", + }), + source({ + targetLabel: "npm:express@5.1.0", + requestedTarget: "npm:express", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + codeIndexState: "STALE", + coverage: { coverageState: "PARTIAL", pagesCrawled: 5 }, + }), + ], + }), + ); + + expect(presentation.targetGroups).toHaveLength(2); + expect( + presentation.targetGroups.map((group) => ({ + requested: group.identity.requested, + fresh: group.identity.fresh, + served: group.identity.served, + sourceRequested: group.sources.flatMap((sourceGroup) => + sourceGroup.entries.map((entry) => entry.requestedTarget), + ), + versions: group.alternatives?.versions.map((entry) => entry.version), + staleLimits: group.trustLimits.filter((limit) => limit.kind === "stale") + .length, + coverageLimits: group.trustLimits.filter( + (limit) => limit.kind === "coverage", + ).length, + })), + ).toEqual([ + { + requested: "npm:express@5.1.0", + fresh: "npm:express@5.1.0", + served: "npm:express@5.1.0", + sourceRequested: ["npm:express@5.1.0"], + versions: ["5.0.0"], + staleLimits: 0, + coverageLimits: 0, + }, + { + requested: "npm:express", + fresh: "npm:express@5.2.1", + served: "npm:express@5.1.0", + sourceRequested: ["npm:express"], + versions: ["5.2.0"], + staleLimits: 1, + coverageLimits: 1, + }, + ]); + }); + + it("normalizes target freshness once in the presentation layer", () => { + expect(classifyTargetFreshness("STALE")).toBe("stale"); + expect(classifyTargetFreshness("fallback_recent")).toBe("stale"); + expect(classifyTargetFreshness("PENDING")).toBe("indexing"); + expect(classifyTargetFreshness("PROVISIONAL")).toBe("provisional"); + expect(classifyTargetFreshness("CURRENT")).toBeUndefined(); + }); + it("classifies stale, fallback, and provisional trust limits", () => { const presentation = projectUnifiedSearchPresentation( completed({ @@ -1298,7 +1392,7 @@ describe("projectUnifiedSearchPresentation", () => { expect(JSON.stringify(presentation)).not.toContain( "Source 'code' is indexing", ); - expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.action).toEqual({ kind: "new_search" }); }); it("suppresses generic pivots for evidence limits and prefers indexed alternatives", () => { diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index f5065d21..21df4f18 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -49,10 +49,14 @@ export type UnifiedSearchSourceReadiness = | "available_not_searched" | "unavailable"; +export type UnifiedSearchFreshnessKind = "stale" | "indexing" | "provisional"; + export interface UnifiedSearchSourceEntry { state: UnifiedSearchSourceReadiness; target: string; searchTarget: string; + targetAliases?: string[]; + requestedTarget?: string; resultCount?: number; repositoryUrl?: string; commitSha?: string; @@ -64,6 +68,8 @@ type SourceIdentity = Pick< UnifiedSearchSourceEntry, | "target" | "searchTarget" + | "targetAliases" + | "requestedTarget" | "repositoryUrl" | "commitSha" | "siteKey" @@ -116,6 +122,8 @@ export interface UnifiedSearchSiteSuggestionFacts { export interface UnifiedSearchTargetGroup { identity: UnifiedSearchTargetPresentation; + freshnessKind?: UnifiedSearchFreshnessKind; + inProgress?: boolean; sources: UnifiedSearchSourceGroup[]; alternatives?: UnifiedSearchAlternativeFacts; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; @@ -136,18 +144,20 @@ export type UnifiedSearchTrustLimit = freshTarget?: string; servedTarget?: string; } - | { kind: "provisional"; target?: string } + | { kind: "provisional"; target?: string; requestedTarget?: string } | { kind: "source"; source: UnifiedSearchSourceKind; state: Exclude; target?: string; + requestedTarget?: string; } | { kind: "coverage"; source: UnifiedSearchSourceKind; state: "partial" | "capped"; target?: string; + requestedTarget?: string; pagesCrawled?: number; frontierRemaining?: number; estimatedTotalPages?: number; @@ -220,6 +230,7 @@ interface SnapshotFacts { interface CandidateSet { target?: string; + requestedTarget?: string; aliases: string[]; versions: UnifiedSearchAlternative[]; refs: UnifiedSearchAlternative[]; @@ -251,6 +262,7 @@ export function projectUnifiedSearchPresentation( alternatives, siteSuggestions, trustLimits, + lifecycle, }); return { @@ -443,6 +455,7 @@ function contributorIdentity( return { target, searchTarget, + ...sourceTargetAliases(entry), ...(contributor.repositoryUrl ? { repositoryUrl: contributor.repositoryUrl } : {}), @@ -467,7 +480,29 @@ function sourceIdentity( : kind === "site_docs" && served?.site ? { siteKey: served.site } : {}; - return { target, searchTarget: target, ...identity }; + return { + target, + searchTarget: target, + ...sourceTargetAliases(entry), + ...identity, + }; +} + +function sourceTargetAliases( + entry: UnifiedSearchSourceStatusPayload, +): Pick { + const aliases = uniqueAliases([ + entry.targetLabel, + entry.requestedTarget, + entry.freshTarget, + entry.servedTarget, + ]); + return { + ...(aliases.length > 1 ? { targetAliases: aliases } : {}), + ...(entry.requestedTarget + ? { requestedTarget: entry.requestedTarget } + : {}), + }; } function sourceTarget(entry: UnifiedSearchSourceStatusPayload): string { @@ -512,7 +547,7 @@ function projectTrustLimits( const add = (limit: UnifiedSearchTrustLimit): void => { const key = limit.kind === "stale" - ? `stale:${limit.servedTarget ?? limit.target ?? ""}` + ? `stale:${limit.requestedTarget ?? ""}:${limit.servedTarget ?? limit.target ?? ""}` : JSON.stringify(limit); const existing = limits.get(key); if ( @@ -533,6 +568,9 @@ function projectTrustLimits( source: group.kind, state: entry.state, target: entry.target, + ...(entry.requestedTarget + ? { requestedTarget: entry.requestedTarget } + : {}), }); } } @@ -569,19 +607,32 @@ function projectTrustLimits( (contributor) => contributor.freshness === "PROVISIONAL", ) ) { - add({ kind: "provisional", target }); + add({ + kind: "provisional", + target, + ...(entry.requestedTarget + ? { requestedTarget: entry.requestedTarget } + : {}), + }); } const kind = sourceKind(entry); - addCoverage(add, kind, target, entry.coverage); + addCoverage(add, kind, target, entry.requestedTarget, entry.coverage); for (const contributor of entry.contributors ?? []) { const contributorTargetValue = contributorIdentity(entry, contributor); if (contributor.freshness === "STALE") { - add({ kind: "stale", target: contributorTargetValue.target }); + add({ + kind: "stale", + target: contributorTargetValue.target, + ...(entry.requestedTarget + ? { requestedTarget: entry.requestedTarget } + : {}), + }); } addCoverage( add, contributor.kind === "DOCPACK" ? "site_docs" : "repository_docs", contributorTargetValue.target, + entry.requestedTarget, contributor.coverage, ); } @@ -606,6 +657,7 @@ function addCoverage( add: (limit: UnifiedSearchTrustLimit) => void, source: UnifiedSearchSourceKind, target: string, + requestedTarget: string | undefined, coverage: Coverage | undefined, ): void { if (!coverage || !["PARTIAL", "CAPPED"].includes(coverage.coverageState)) { @@ -616,6 +668,7 @@ function addCoverage( source, state: coverage.coverageState.toLowerCase() as "partial" | "capped", target, + ...(requestedTarget ? { requestedTarget } : {}), pagesCrawled: coverage.pagesCrawled, frontierRemaining: typeof coverage.frontierRemaining === "number" @@ -671,6 +724,7 @@ function projectAlternatives( const candidates: CandidateSet[] = [ ...(progress?.targets ?? []).map((target) => ({ target: target.requested ?? target.resolvedRequested ?? target.served, + requestedTarget: target.requested, aliases: uniqueAliases([ target.requested, target.resolvedRequested, @@ -690,7 +744,8 @@ function projectAlternatives( return resolution ? [ { - target: sourceTarget(entry), + target: entry.requestedTarget ?? sourceTarget(entry), + requestedTarget: entry.requestedTarget, aliases: uniqueAliases([ sourceTarget(entry), entry.targetLabel, @@ -729,6 +784,7 @@ interface TargetGroupInput { alternatives: UnifiedSearchAlternativeFacts[]; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; + lifecycle: UnifiedSearchLifecycle; } function projectTargetGroups( @@ -737,19 +793,21 @@ function projectTargetGroups( const groups: UnifiedSearchTargetGroup[] = []; for (const identity of input.targets) { const existing = groups.find((group) => - targetIdentityValues(identity).some((target) => - targetIdentityValues(group.identity).includes(target), - ), + targetIdentitiesMatch(group.identity, identity), ); if (existing) { existing.identity.requested ??= identity.requested; existing.identity.fresh ??= identity.fresh; existing.identity.served ??= identity.served; existing.identity.freshness ??= identity.freshness; + existing.freshnessKind ??= classifyTargetFreshness(identity.freshness); + existing.inProgress ||= input.lifecycle.kind === "active"; continue; } groups.push({ identity: { ...identity }, + freshnessKind: classifyTargetFreshness(identity.freshness), + inProgress: input.lifecycle.kind === "active", sources: [], siteSuggestions: [], trustLimits: [], @@ -765,13 +823,16 @@ function projectTargetGroups( const findOrCreateForAliases = ( aliases: string[], target: string | undefined, + requestedTarget?: string, ): UnifiedSearchTargetGroup => { - const existing = groups.find((group) => - aliases.some((alias) => targetGroupMatches(group, alias)), - ); + const existing = findMatchingTargetGroup(groups, aliases, requestedTarget); if (existing) return existing; const created: UnifiedSearchTargetGroup = { - identity: target ? { requested: target } : {}, + identity: + requestedTarget || target + ? { requested: requestedTarget ?? target } + : {}, + inProgress: input.lifecycle.kind === "active", sources: [], siteSuggestions: [], trustLimits: [], @@ -793,7 +854,9 @@ function projectTargetGroups( limit.servedTarget, limit.target, ]); - const group = findOrCreateForAliases(aliases, aliases[0]); + const group = + findMatchingTargetGroup(groups, aliases, limit.requestedTarget) ?? + findOrCreateForAliases(aliases, aliases[0], limit.requestedTarget); if (limit.requestedTarget) group.identity.requested = limit.requestedTarget; if (limit.freshTarget) group.identity.fresh = limit.freshTarget; if (limit.servedTarget) group.identity.served = limit.servedTarget; @@ -801,7 +864,14 @@ function projectTargetGroups( for (const sourceGroup of input.sources) { for (const entry of sourceGroup.entries) { - const group = findOrCreate(entry.searchTarget); + const aliases = entry.targetAliases ?? [entry.searchTarget]; + const group = + findMatchingTargetGroup(groups, aliases, entry.requestedTarget) ?? + findOrCreateForAliases( + aliases, + entry.searchTarget, + entry.requestedTarget, + ); const existingSource = group.sources.find( (candidate) => candidate.kind === sourceGroup.kind, ); @@ -811,7 +881,11 @@ function projectTargetGroups( } for (const alternatives of input.alternatives) { - findOrCreate(alternatives.target).alternatives = alternatives; + const aliases = uniqueAliases([alternatives.target]); + const group = + findMatchingTargetGroup(groups, aliases, alternatives.target) ?? + findOrCreateForAliases(aliases, alternatives.target); + group.alternatives = alternatives; } for (const suggestion of input.siteSuggestions) { findOrCreate(suggestion.target).siteSuggestions.push(suggestion); @@ -821,13 +895,26 @@ function projectTargetGroups( continue; } const target = "target" in limit ? limit.target : undefined; - const sourceGroup = groups.find((group) => - targetGroupMatches(group, target), + const requestedTarget = + "requestedTarget" in limit ? limit.requestedTarget : undefined; + const aliases = + limit.kind === "stale" + ? uniqueAliases([ + limit.requestedTarget, + limit.freshTarget, + limit.servedTarget, + limit.target, + ]) + : uniqueAliases([requestedTarget, target]); + const sourceGroup = findMatchingTargetGroup( + groups, + aliases, + requestedTarget, ); const group = sourceGroup ?? (groups.length === 1 ? groups[0] : undefined) ?? - findOrCreate(target); + findOrCreateForAliases(aliases, target, requestedTarget); if (limit.kind === "stale") { if (limit.requestedTarget) group.identity.requested = limit.requestedTarget; @@ -854,21 +941,57 @@ function targetIdentityValues( ); } -function targetGroupMatches( +function targetGroupMatchesAliases( group: UnifiedSearchTargetGroup, - target: string | undefined, + aliases: string[], ): boolean { - if (!target) return false; return ( - targetIdentityValues(group.identity).includes(target) || + targetIdentityValues(group.identity).some((value) => + aliases.includes(value), + ) || group.sources.some((source) => - source.entries.some( - (entry) => entry.target === target || entry.searchTarget === target, + source.entries.some((entry) => + (entry.targetAliases ?? [entry.target, entry.searchTarget]).some( + (value) => value !== undefined && aliases.includes(value), + ), ), ) ); } +function findMatchingTargetGroup( + groups: UnifiedSearchTargetGroup[], + aliases: string[], + requestedTarget?: string, +): UnifiedSearchTargetGroup | undefined { + if (requestedTarget) { + const requestedMatch = groups.find( + (group) => group.identity.requested === requestedTarget, + ); + if (requestedMatch) return requestedMatch; + } + const matches = groups.filter((group) => + targetGroupMatchesAliases(group, aliases), + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function targetIdentitiesMatch( + left: UnifiedSearchTargetPresentation, + right: UnifiedSearchTargetPresentation, +): boolean { + if ( + left.requested !== undefined && + right.requested !== undefined && + left.requested !== right.requested + ) { + return false; + } + return targetIdentityValues(left).some((target) => + targetIdentityValues(right).includes(target), + ); +} + function uniqueAliases(values: Array): string[] { return [ ...new Set(values.filter((value): value is string => Boolean(value))), @@ -886,13 +1009,35 @@ export function targetDisplayFamilyKey(target: string | undefined): string { : normalized.replace(/@[^#]+$/, ""); } +export function classifyTargetFreshness( + freshness: string | undefined, +): UnifiedSearchFreshnessKind | undefined { + switch (freshness?.toLowerCase()) { + case "stale": + case "fallback_recent": + return "stale"; + case "indexing": + case "pending": + return "indexing"; + case "provisional": + return "provisional"; + default: + return undefined; + } +} + function mergeAlternativeCandidates( candidates: CandidateSet[], ): CandidateSet[] { const merged: CandidateSet[] = []; for (const candidate of candidates) { - const existing = merged.find((value) => - candidate.aliases.some((alias) => value.aliases.includes(alias)), + const existing = merged.find( + (value) => + !( + candidate.requestedTarget && + value.requestedTarget && + candidate.requestedTarget !== value.requestedTarget + ) && candidate.aliases.some((alias) => value.aliases.includes(alias)), ); if (existing) { existing.aliases = uniqueAliases([ @@ -902,9 +1047,11 @@ function mergeAlternativeCandidates( existing.versions.push(...candidate.versions); existing.refs.push(...candidate.refs); existing.suggestedRefs.push(...candidate.suggestedRefs); + existing.requestedTarget ??= candidate.requestedTarget; } else { merged.push({ target: candidate.target, + requestedTarget: candidate.requestedTarget, aliases: [...candidate.aliases], versions: [...candidate.versions], refs: [...candidate.refs], @@ -1002,7 +1149,7 @@ function projectAction(input: ActionInput): UnifiedSearchAction { limit.kind === "stale", ) ) { - return { kind: "none" }; + return { kind: "new_search" }; } if ( diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index a23a0e4c..82e5ae7f 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -239,7 +239,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Searched: docs (npm:express@4.18.2)"); + expect(text).toContain("- npm:express@4.18.2\n Searched: docs"); expect(text).not.toContain("repository docs"); }, ); @@ -375,12 +375,60 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("Waiting:"); expect(text).not.toContain("Searched:"); expect(text).not.toContain("n8n.io"); + expect(text).toContain("Status: indexing | Ready now: versions 2.26.9"); expect(text).toContain("versions 2.26.9"); expect(text).toContain( 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', ); }); + it("gives a requested-only active target a current-state detail", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 100, + targets: [{ requested: "npm:express" }], + }, + }), + ); + + expect(text).toContain("- npm:express\n Status: indexing"); + }); + + it("keeps shared served snapshots in distinct requested target blocks", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 2, + elapsedMs: 100, + targets: [ + { + requested: "npm:express@5.1.0", + resolvedRequested: "npm:express@5.1.0", + served: "npm:express@5.1.0", + }, + { + requested: "npm:express", + resolvedRequested: "npm:express@5.2.1", + served: "npm:express@5.1.0", + freshness: "INDEXING", + }, + ], + }, + }), + ); + + expect(text).toContain("- npm:express@5.1.0"); + expect(text).toContain("- npm:express -> 5.2.1"); + expect(text.match(/^- npm:express/gm)).toHaveLength(2); + expect(text).toContain("Search ref_abc-123 | 0/2 targets ready"); + }); + it("renders an initial progress-only parser warning once below the outcome", () => { const text = renderUnifiedSearchSuccess( incomplete({ diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 67f23278..67768b80 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -310,9 +310,7 @@ function appendPresentationTargetGroup( const identityIsStale = !stale && Boolean(group.identity.served) && - ["STALE", "INDEXING", "stale", "indexing", "fallback_recent"].includes( - group.identity.freshness ?? "", - ) && + (group.freshnessKind === "stale" || group.freshnessKind === "indexing") && group.identity.served !== (group.identity.fresh ?? group.identity.requested); if (stale || identityIsStale) { @@ -350,11 +348,15 @@ function appendPresentationTargetGroup( if ( details.length === 0 && - ["INDEXING", "PENDING", "PROVISIONAL", "indexing", "provisional"].includes( - group.identity.freshness ?? "", - ) + (group.inProgress || group.freshnessKind !== undefined) ) { - details.push("Indexing"); + details.push( + group.freshnessKind === "provisional" + ? "Status: provisional" + : group.freshnessKind === "stale" + ? "Status: older snapshot" + : "Status: indexing", + ); } const ready = formatTargetAlternatives(group.alternatives); @@ -401,7 +403,7 @@ function formatGroupedSource( ? "repository docs" : source.kind === "site_docs" ? `${formatDocumentationSourceIdentity(source, entry)} docs` - : `docs (${entry.target})`; + : "docs"; const qualifiers: string[] = []; if (entry.state === "available_not_searched") qualifiers.push("not searched"); if (coverageDetails) qualifiers.push(coverageDetails); @@ -435,9 +437,8 @@ function formatTargetGroupIdentity(group: UnifiedSearchTargetGroup): string { const primary = requested ?? fresh ?? served ?? "target"; const staleLike = group.trustLimits.some((limit) => limit.kind === "stale") || - ["STALE", "INDEXING", "stale", "indexing", "fallback_recent"].includes( - group.identity.freshness ?? "", - ); + group.freshnessKind === "stale" || + group.freshnessKind === "indexing"; const resolved = fresh ?? (staleLike ? undefined : served); const resolution = resolved && resolved !== primary diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index c69b322e..70578094 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -193,6 +193,46 @@ describe("runMcpSmoke", () => { }, ); + it.each([ + "Next: githits search-status smoke-ref --wait 20", + "Next: githits code read npm:express index.js", + "Next: githits docs read page-1 --offset 10", + ])("rejects CLI syntax leaked into MCP search text: %s", async (action) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + smokeSearchText().replace( + 'Next: search_status search_ref="smoke-ref" wait_timeout_ms=20000', + action, + ), + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: CLI command syntax leaked into MCP output", + ); + }); + + it("requires Using details to remain grouped under a target", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + smokeSearchText().replace( + "- npm:express@5.2.1\n Indexing: code | Ready now: versions 5.2.1", + " Using: 5.1.0 while 5.2.1 indexes", + ), + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: readiness details must be grouped under a target", + ); + }); + it.each([ ["Ready:", "legacy flat section Ready:"], ["Waiting:", "legacy flat section Waiting:"], diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 98e4dbd9..28720022 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -243,7 +243,9 @@ function assertSearchDefaultText(text: string, context: string): void { ); const hasReadinessText = lines.some((line) => - /^ {2}(?:Indexing|Searched|Ready now|Unavailable):/.test(line), + /^ {2}(?! {2}).*(?:Indexing|Searched|Ready now|Unavailable|Using|Status):/.test( + line, + ), ); if (hasReadinessText) { assert( @@ -292,6 +294,14 @@ function assertSearchDefaultText(text: string, context: string): void { `${context}: session summary does not match search_ref action`, ); } + assert( + !formatterText.includes("githits search-status ") && + !formatterText.includes("githits code read ") && + !formatterText.includes("githits docs read ") && + !formatterText.includes(" --wait ") && + !formatterText.includes(" --offset "), + `${context}: CLI command syntax leaked into MCP output`, + ); assert( hasHitLocator(lines, isMcpCodeReadLocator) || hasHitLocator(lines, isMcpDocsReadLocator) || diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 0ab60f14..5ff69fd2 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -433,7 +433,9 @@ export function assertSearchTerminalText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?:Indexing|Searched|Ready now|Unavailable):/.test(line), + /^ {2}(?! {2}).*(?:Indexing|Searched|Ready now|Unavailable|Using|Status):/.test( + line, + ), ); if (hasReadinessText) { assert( diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 023aef87..f8f8fbdf 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -113,6 +113,18 @@ More hits available. Pass --offset 10 or --limit N to widen.`; ).not.toThrow(); }); + it("requires Using details to remain grouped under a target", () => { + expect(() => + assertSearchTerminalText( + valid.replace( + "- npm:n8n -> 2.36.7\n Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", + " Using: 2.26.9 while 2.36.7 indexes", + ), + "search", + ), + ).toThrow("readiness details must be grouped under a target"); + }); + it("rejects duplicate search session summaries", () => { expect(() => assertSearchTerminalText( diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 7755ba01..e67d623d 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -2171,7 +2171,7 @@ describe("searchStatusAction", () => { expect(output).toContain( "- github:expressjs/express#refs/heads/master -> master", ); - expect(output).toContain("Indexing | Ready now: refs master"); + expect(output).toContain("Status: indexing | Ready now: refs master"); expect(output).toContain("Search search-ref-123 | 0/1 target ready"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", From d714cfca349a1af8495807e034939da1868d0e24 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 01:01:38 +0300 Subject: [PATCH 34/46] fix: report exact search target state Render status only from explicit freshness, preserve contributor identities in alias matching, and resolve ambiguous source rows by unique requested target. Document the resulting status and recovery anatomy. --- docs/implementation/tools.md | 21 +++-- .../unified-search-presentation.test.ts | 89 +++++++++++++++++-- .../src/shared/unified-search-presentation.ts | 27 ++++-- .../src/shared/unified-search-text.test.ts | 31 ++++++- .../mcp/src/shared/unified-search-text.ts | 30 ++++--- 5 files changed, 160 insertions(+), 38 deletions(-) diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index e19dde2e..5bfa3189 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -144,11 +144,13 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` and coverage in JSON. Text places the user-meaningful readiness -state under its target, using `Indexing`, `Searched`, `Ready now`, or -`Unavailable` details as applicable. Site identity, stale/provisional qualifiers, -and partial or capped coverage remain attached to that target; internal reason -codes and indexing references stay in JSON. Partial/capped coverage is published -evidence, not a progress or retry signal. +state under its target, using `Indexing`, `Searched`, `Ready now`, `Unavailable`, +`Using`, or `Status` details as applicable. `Status` appears only when the +backend supplies an explicit current, pending, indexing, provisional, or stale +target state; session activity alone does not invent target state. Site identity, +stale/provisional qualifiers, and partial or capped coverage remain attached to +that target; internal reason codes and indexing references stay in JSON. +Partial/capped coverage is published evidence, not a progress or retry signal. `evidenceNotice` is carried once on initial and stored result envelopes. JSON retains that exact backend-owned notice; default text does not render it or replace @@ -368,10 +370,11 @@ CLI uses `--offset N` / `--limit N`. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. Completed empty search uses the model's applicable action: generic query pivots are -suppressed for evidence-limited or unsearched sources, indexing/provisional -evidence prefers waiting or an indexed alternative, standalone site searches -expose only a shorter/broader site query, and filter removal or symbol/code-grep -pivots appear only when applicable. Surface-native pivots name +suppressed for evidence-limited or unsearched sources, which instead direct the +caller to rerun the search later. Indexing/provisional evidence prefers waiting +or an indexed alternative, standalone site searches expose only a +shorter/broader site query, and filter removal or symbol/code-grep pivots appear +only when applicable. Surface-native pivots name `source="symbol"` / `code_grep` in MCP and `--source symbol` / `githits code grep` in CLI. A result with both an evidence notice and `searchRef` emits one status continuation. Terminal `DEFERRED`, `FAILED`, and diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 1f6c2f17..59203803 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -841,7 +841,6 @@ describe("projectUnifiedSearchPresentation", () => { fresh: "npm:n8n@2.36.7", freshness: "INDEXING", }, - inProgress: true, sources: [ { kind: "code", @@ -1186,9 +1185,6 @@ describe("projectUnifiedSearchPresentation", () => { sourceStatus: [ source({ targetLabel: "npm:express@5.1.0", - requestedTarget: "npm:express@5.1.0", - freshTarget: "npm:express@5.1.0", - servedTarget: "npm:express@5.1.0", codeIndexState: "CURRENT", }), source({ @@ -1224,7 +1220,7 @@ describe("projectUnifiedSearchPresentation", () => { requested: "npm:express@5.1.0", fresh: "npm:express@5.1.0", served: "npm:express@5.1.0", - sourceRequested: ["npm:express@5.1.0"], + sourceRequested: [undefined], versions: ["5.0.0"], staleLimits: 0, coverageLimits: 0, @@ -1241,12 +1237,91 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); + it("keeps contributor limits with their target when parent aliases diverge", () => { + const repositoryUrl = "https://github.com/example/one"; + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status: "INDEXING", + targetsReady: 1, + targetsTotal: 2, + elapsedMs: 200, + targets: [ + { + requested: "npm:one@1.0.0", + resolvedRequested: "npm:one@1.1.0", + served: "npm:one@1.0.0", + freshness: "INDEXING", + }, + { + requested: "npm:two@2.0.0", + resolvedRequested: "npm:two@2.0.0", + freshness: "CURRENT", + }, + ], + }, + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:one@1.0.0", + freshTarget: "npm:one@1.1.0", + servedTarget: "npm:one@1.0.0", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + freshness: "STALE", + resultCount: 0, + repositoryUrl, + coverage: { coverageState: "PARTIAL", pagesCrawled: 5 }, + }, + ], + }), + source({ + targetLabel: "npm:two@2.0.0", + codeIndexState: "CURRENT", + }), + ], + }), + ); + + expect(presentation.targetGroups).toHaveLength(2); + const first = presentation.targetGroups.find( + (group) => group.identity.requested === "npm:one@1.0.0", + ); + expect(first?.sources).toEqual([ + { + kind: "repository_docs", + entries: [ + expect.objectContaining({ + target: repositoryUrl, + searchTarget: "npm:one@1.0.0", + }), + ], + }, + ]); + expect(first?.trustLimits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "stale", target: repositoryUrl }), + expect.objectContaining({ kind: "coverage", target: repositoryUrl }), + ]), + ); + expect( + presentation.targetGroups.some( + (group) => group.identity.requested === repositoryUrl, + ), + ).toBe(false); + }); + it("normalizes target freshness once in the presentation layer", () => { expect(classifyTargetFreshness("STALE")).toBe("stale"); expect(classifyTargetFreshness("fallback_recent")).toBe("stale"); - expect(classifyTargetFreshness("PENDING")).toBe("indexing"); + expect(classifyTargetFreshness("PENDING")).toBe("pending"); + expect(classifyTargetFreshness("CURRENT")).toBe("current"); + expect(classifyTargetFreshness("INDEXED")).toBe("current"); expect(classifyTargetFreshness("PROVISIONAL")).toBe("provisional"); - expect(classifyTargetFreshness("CURRENT")).toBeUndefined(); + expect(classifyTargetFreshness("FUTURE_STATE")).toBeUndefined(); }); it("classifies stale, fallback, and provisional trust limits", () => { diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 21df4f18..39da2d72 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -49,7 +49,12 @@ export type UnifiedSearchSourceReadiness = | "available_not_searched" | "unavailable"; -export type UnifiedSearchFreshnessKind = "stale" | "indexing" | "provisional"; +export type UnifiedSearchFreshnessKind = + | "current" + | "stale" + | "indexing" + | "pending" + | "provisional"; export interface UnifiedSearchSourceEntry { state: UnifiedSearchSourceReadiness; @@ -123,7 +128,6 @@ export interface UnifiedSearchSiteSuggestionFacts { export interface UnifiedSearchTargetGroup { identity: UnifiedSearchTargetPresentation; freshnessKind?: UnifiedSearchFreshnessKind; - inProgress?: boolean; sources: UnifiedSearchSourceGroup[]; alternatives?: UnifiedSearchAlternativeFacts; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; @@ -262,7 +266,6 @@ export function projectUnifiedSearchPresentation( alternatives, siteSuggestions, trustLimits, - lifecycle, }); return { @@ -784,7 +787,6 @@ interface TargetGroupInput { alternatives: UnifiedSearchAlternativeFacts[]; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; - lifecycle: UnifiedSearchLifecycle; } function projectTargetGroups( @@ -801,13 +803,11 @@ function projectTargetGroups( existing.identity.served ??= identity.served; existing.identity.freshness ??= identity.freshness; existing.freshnessKind ??= classifyTargetFreshness(identity.freshness); - existing.inProgress ||= input.lifecycle.kind === "active"; continue; } groups.push({ identity: { ...identity }, freshnessKind: classifyTargetFreshness(identity.freshness), - inProgress: input.lifecycle.kind === "active", sources: [], siteSuggestions: [], trustLimits: [], @@ -832,7 +832,6 @@ function projectTargetGroups( requestedTarget || target ? { requested: requestedTarget ?? target } : {}, - inProgress: input.lifecycle.kind === "active", sources: [], siteSuggestions: [], trustLimits: [], @@ -951,7 +950,7 @@ function targetGroupMatchesAliases( ) || group.sources.some((source) => source.entries.some((entry) => - (entry.targetAliases ?? [entry.target, entry.searchTarget]).some( + [...(entry.targetAliases ?? []), entry.target, entry.searchTarget].some( (value) => value !== undefined && aliases.includes(value), ), ), @@ -970,6 +969,12 @@ function findMatchingTargetGroup( ); if (requestedMatch) return requestedMatch; } + const directRequestedMatches = groups.filter( + (group) => + group.identity.requested !== undefined && + aliases.includes(group.identity.requested), + ); + if (directRequestedMatches.length === 1) return directRequestedMatches[0]; const matches = groups.filter((group) => targetGroupMatchesAliases(group, aliases), ); @@ -1013,12 +1018,16 @@ export function classifyTargetFreshness( freshness: string | undefined, ): UnifiedSearchFreshnessKind | undefined { switch (freshness?.toLowerCase()) { + case "current": + case "indexed": + return "current"; case "stale": case "fallback_recent": return "stale"; case "indexing": - case "pending": return "indexing"; + case "pending": + return "pending"; case "provisional": return "provisional"; default: diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 82e5ae7f..9e92cdb3 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -382,7 +382,7 @@ describe("renderUnifiedSearchSuccess", () => { ); }); - it("gives a requested-only active target a current-state detail", () => { + it("does not invent a target state when progress omits freshness", () => { const text = renderUnifiedSearchSuccess( incomplete({ progress: { @@ -395,9 +395,36 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:express\n Status: indexing"); + expect(text).toContain("- npm:express"); + expect(text).not.toContain("Status:"); }); + it.each([ + ["CURRENT", "Status: ready"], + ["INDEXED", "Status: ready"], + ["PENDING", "Status: pending"], + ["INDEXING", "Status: indexing"], + ["PROVISIONAL", "Status: provisional"], + ] as const)( + "renders explicit target freshness %s accurately", + (freshness, detail) => { + const text = renderUnifiedSearchSuccess( + incomplete({ + progress: { + status: "SEARCHING", + targetsReady: + freshness === "CURRENT" || freshness === "INDEXED" ? 1 : 0, + targetsTotal: 1, + elapsedMs: 100, + targets: [{ requested: "npm:express@5.2.1", freshness }], + }, + }), + ); + + expect(text).toContain(detail); + }, + ); + it("keeps shared served snapshots in distinct requested target blocks", () => { const text = renderUnifiedSearchSuccess( incomplete({ diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 67768b80..09e67751 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -346,17 +346,8 @@ function appendPresentationTargetGroup( details.push(`${label}: ${[...new Set(values)].join(", ")}`); } - if ( - details.length === 0 && - (group.inProgress || group.freshnessKind !== undefined) - ) { - details.push( - group.freshnessKind === "provisional" - ? "Status: provisional" - : group.freshnessKind === "stale" - ? "Status: older snapshot" - : "Status: indexing", - ); + if (details.length === 0 && group.freshnessKind !== undefined) { + details.push(`Status: ${formatTargetStatus(group.freshnessKind)}`); } const ready = formatTargetAlternatives(group.alternatives); @@ -384,6 +375,23 @@ function appendPresentationTargetGroup( } } +function formatTargetStatus( + freshness: NonNullable, +): string { + switch (freshness) { + case "current": + return "ready"; + case "pending": + return "pending"; + case "provisional": + return "provisional"; + case "stale": + return "older snapshot"; + case "indexing": + return "indexing"; + } +} + function formatGroupedSource( source: UnifiedSearchSourceGroup, entry: UnifiedSearchSourceEntry, From 5577670e7a20062ec4bc16547b2c43e7a3038729 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 08:38:05 +0300 Subject: [PATCH 35/46] fix: adapt search output to terminal width Use the shared formatter's width option for CLI output, simplify available-source wording, and render versionless alternatives as refs. --- changes/search-output-hierarchy.changed.md | 2 +- docs/implementation/cli-commands.md | 7 ++-- docs/implementation/mcp-cli-parity.md | 6 ++- docs/implementation/tools.md | 2 +- .../unified-search-presentation.test.ts | 29 +++++++++++++++ .../src/shared/unified-search-presentation.ts | 9 ++++- .../src/shared/unified-search-text.test.ts | 37 +++++++++++++------ .../mcp/src/shared/unified-search-text.ts | 30 ++++++++++----- packages/mcp/src/smoke-test.test.ts | 6 +-- packages/mcp/src/smoke-test.ts | 2 +- packages/mcp/src/tools/search-status.test.ts | 4 +- src/commands/search.test.ts | 27 +++++++------- src/commands/search.ts | 1 + 13 files changed, 110 insertions(+), 52 deletions(-) diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md index fb950dcc..cdcfe1a5 100644 --- a/changes/search-output-hierarchy.changed.md +++ b/changes/search-output-hierarchy.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with target-grouped readiness, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance. +- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with target-grouped readiness, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index c412f305..09e2c762 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -244,15 +244,14 @@ The representative CLI n8n active-empty output shape is: Indexing - no results yet - npm:n8n -> 2.36.7 - Indexing: code, repository docs | Ready now: n8n.io docs (not searched; - pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, - master + Indexing: code, repository docs | Available now: n8n.io docs ( pages; + capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master Search | 0/1 target ready Next: githits search-status --wait 20 ``` -**Highlighting.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, target details remain plain, and the optional session row is dim. Color never carries meaning and does not change wording or wrapping. +**Highlighting and width.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, target details remain plain, and the optional session row is dim. Color never carries meaning or changes wording. CLI target details and hit summaries wrap to the current terminal width; MCP uses the shared 80-column fallback. **Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index bb403dbc..cfd97157 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -238,8 +238,10 @@ test suite anchors the doc. CLI human `search` / `search-status` and MCP `search` / `search_status` default `text-v1` use one shared formatter. The presentation model owns target groups, readiness, trust limits, and action selection; the text renderer owns wording, -wrapping, hit anatomy, and ordering. Callers provide only ANSI enablement and -surface-native action syntax. The order is outcome headline, target blocks with +wrapping, hit anatomy, and ordering. Callers provide ANSI enablement, +surface-native action syntax, and an optional output width. CLI supplies its +current terminal width; MCP uses the formatter's 80-column default. The order is +outcome headline, target blocks with identity plus grouped readiness/usable alternatives, warnings and results, an optional session summary, and one positive next action. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 5bfa3189..d8f83cf1 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -144,7 +144,7 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` and coverage in JSON. Text places the user-meaningful readiness -state under its target, using `Indexing`, `Searched`, `Ready now`, `Unavailable`, +state under its target, using `Indexing`, `Searched`, `Available now`, `Unavailable`, `Using`, or `Status` details as applicable. `Status` appears only when the backend supplies an explicit current, pending, indexing, provisional, or stale target state; session activity alone does not invent target state. Site identity, diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 59203803..2d274591 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -676,6 +676,35 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); + it("classifies versionless available-version entries as refs", () => { + const sha = "df0abc9333a3398b97b71f6ea7cd77d5ea3e9f97"; + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.1.1", + targetResolution: { + availableVersions: [ + { ref: sha }, + { version: "4.0.0", ref: "v4.0.0" }, + ], + availableRefs: [{ ref: "master" }], + }, + }), + ], + }), + ); + + expect(presentation.alternatives[0]?.versions).toEqual([ + { version: "4.0.0", ref: "v4.0.0" }, + ]); + expect(presentation.alternatives[0]?.refs).toEqual([ + { ref: sha }, + { ref: "master" }, + ]); + }); + it.each([ ["resolvedRequested", "npm:express@5.2.1"], ["served", "npm:express@5.1.0"], diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 39da2d72..098d4631 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1094,8 +1094,13 @@ function boundedAlternatives( } return { values: display, remaining }; }; - const versionFacts = bounded(versions); - const refFacts = bounded(refs); + const versionFacts = bounded( + versions.filter((alternative) => alternative.version !== undefined), + ); + const refFacts = bounded([ + ...versions.filter((alternative) => alternative.version === undefined), + ...refs, + ]); const suggestedRefFacts = bounded(suggestedRefs); return { versions: versionFacts.values, diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 9e92cdb3..3e8d8fa4 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -250,9 +250,8 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toBe( "Indexing - no results yet\n\n" + "- npm:n8n -> 2.36.7\n" + - " Indexing: code, repository docs | Ready now: n8n.io docs (not searched;\n" + - " 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD,\n" + - " master\n\n" + + " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages;\n" + + " capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master\n\n" + "Search fabUr1S3MEVeSgD93pMoSQ | 0/1 target ready\n" + 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', ); @@ -261,7 +260,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("freshnessReason"); expect(text).not.toContain("Opaque evidence notice"); expect(text.match(/Indexing/g)).toHaveLength(2); - expect(text.match(/Ready now:/g)).toHaveLength(1); + expect(text.match(/Available now:/g)).toHaveLength(1); expect(text.match(/Next:/g)).toHaveLength(1); }); @@ -375,7 +374,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("Waiting:"); expect(text).not.toContain("Searched:"); expect(text).not.toContain("n8n.io"); - expect(text).toContain("Status: indexing | Ready now: versions 2.26.9"); + expect(text).toContain("Status: indexing | Available now: versions 2.26.9"); expect(text).toContain("versions 2.26.9"); expect(text).toContain( 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', @@ -498,9 +497,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Searched: example.com/reference docs | Ready now: example.com/guide docs", + "Searched: example.com/reference docs | Available now: example.com/guide docs", ); - expect(text).toContain("(not searched)"); + expect(text).not.toContain("not searched"); expect(text).not.toContain("for npm:example@1.0.0"); }); @@ -716,7 +715,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain("- npm:two@2.0.0\n Searched: code"); expect(text).toContain( - "- site:docs.one.example\n Ready now: site:docs.one.example docs (not searched)", + "- site:docs.one.example\n Available now: site:docs.one.example docs", ); expect(text).not.toContain("for site:"); }); @@ -1165,7 +1164,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("[1] cline/cline@v3.4.2"); expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); expect(text).toContain( - "Ready now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", + "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", ); expect(text).toContain("More hits available. Pass offset=10"); expect(cliText).toContain( @@ -1228,10 +1227,10 @@ describe("renderUnifiedSearchSuccess", () => { const lines = text.split("\n"); const summaryLines = lines.filter((line) => - /^( {2})?(Indexing|Searched|Ready now|Suggested sites)/.test(line), + /^( {2})?(Indexing|Searched|Available now|Suggested sites)/.test(line), ); expect(summaryLines.length).toBeGreaterThanOrEqual(3); - expect(summaryLines.every((line) => line.length <= 76)).toBe(true); + expect(summaryLines.every((line) => line.length <= 80)).toBe(true); expect(text).toContain(targetOne); expect(text).toContain(targetTwo); expect(text).toContain(longRef); @@ -1240,11 +1239,25 @@ describe("renderUnifiedSearchSuccess", () => { "Next: search indexed version 1.0.0 for npm:one-long-package@1.0.0.", ); - const overlongLines = lines.filter((line) => line.length > 76); + const overlongLines = lines.filter((line) => line.length > 80); expect(overlongLines).toHaveLength(1); expect(overlongLines[0]).toContain(longRef); }); + it("wraps target details at the caller-supplied full output width", () => { + const narrow = renderUnifiedSearchSuccess(n8nActiveEmpty(), { width: 60 }); + const wide = renderUnifiedSearchSuccess(n8nActiveEmpty(), { width: 140 }); + const detailLines = (text: string) => + text.split("\n").filter((line) => line.startsWith(" ")); + + expect(detailLines(narrow).length).toBeGreaterThan( + detailLines(wide).length, + ); + expect(detailLines(narrow).every((line) => line.length <= 60)).toBe(true); + expect(detailLines(wide).every((line) => line.length <= 140)).toBe(true); + expect(wide).toContain("n8n.io docs (1,480 pages; capped), versions"); + }); + it("shows capped searched coverage without repeating the trust limit", () => { const text = renderUnifiedSearchSuccess( completed([], { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 09e67751..dc0578b1 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -38,7 +38,7 @@ import type { UnifiedSearchIncompletePayload, } from "./unified-search-response.js"; -const SUMMARY_WRAP_WIDTH = 76; +const DEFAULT_TEXT_WIDTH = 80; const SEP = " | "; type SearchSuccessPayload = @@ -62,6 +62,8 @@ export interface UnifiedSearchTextOptions { useColors?: boolean; /** Surface-native syntax for the continuation action. */ actionSyntax?: "mcp" | "cli"; + /** Full output width, including indentation. Defaults to 80 columns. */ + width?: number; } export interface UnifiedSearchTextResult { @@ -130,6 +132,7 @@ function formatPaginationHint( interface NormalizedTextOptions { useColors: boolean; actionSyntax: "mcp" | "cli"; + width: number; } function normalizeTextOptions( @@ -138,6 +141,10 @@ function normalizeTextOptions( return { useColors: options.useColors ?? false, actionSyntax: options.actionSyntax ?? "mcp", + width: + typeof options.width === "number" && Number.isFinite(options.width) + ? Math.max(20, Math.floor(options.width)) + : DEFAULT_TEXT_WIDTH, }; } @@ -330,7 +337,7 @@ function appendPresentationTargetGroup( }> = [ { state: "waiting", label: "Indexing" }, { state: "searched", label: "Searched" }, - { state: "available_not_searched", label: "Ready now" }, + { state: "available_not_searched", label: "Available now" }, { state: "unavailable", label: "Unavailable" }, ]; for (const { state, label } of states) { @@ -353,11 +360,11 @@ function appendPresentationTargetGroup( const ready = formatTargetAlternatives(group.alternatives); if (ready) { const readyIndex = details.findIndex((detail) => - detail.startsWith("Ready now:"), + detail.startsWith("Available now:"), ); if (readyIndex >= 0) details[readyIndex] = `${details[readyIndex]}, ${ready}`; - else details.push(`Ready now: ${ready}`); + else details.push(`Available now: ${ready}`); } const suggestions = [ @@ -371,7 +378,7 @@ function appendPresentationTargetGroup( } if (details.length > 0) { - lines.push(...wrapHangingText(details.join(" | "), " ")); + lines.push(...wrapHangingText(details.join(" | "), " ", options.width)); } } @@ -413,7 +420,6 @@ function formatGroupedSource( ? `${formatDocumentationSourceIdentity(source, entry)} docs` : "docs"; const qualifiers: string[] = []; - if (entry.state === "available_not_searched") qualifiers.push("not searched"); if (coverageDetails) qualifiers.push(coverageDetails); return `${identity}${qualifiers.length > 0 ? ` (${qualifiers.join("; ")})` : ""}`; } @@ -489,8 +495,12 @@ function formatTargetAlternatives( return categories.length > 0 ? categories.join(", ") : undefined; } -function wrapHangingText(text: string, prefix: string): string[] { - return wrapText(text, SUMMARY_WRAP_WIDTH - prefix.length).map( +function wrapHangingText( + text: string, + prefix: string, + width: number, +): string[] { + return wrapText(text, Math.max(1, width - prefix.length)).map( (line) => `${prefix}${line}`, ); } @@ -685,7 +695,7 @@ function appendHit( for (const wrapped of wrapHighlightedText( hit.summary, hit.highlights?.summary, - SUMMARY_WRAP_WIDTH, + Math.max(1, options.width - 4), options.useColors, )) { lines.push(` ${wrapped}`); @@ -820,7 +830,7 @@ function formatDetailValue(value: unknown): string { return JSON.stringify(value); } -function wrapText(text: string, width = SUMMARY_WRAP_WIDTH): string[] { +function wrapText(text: string, width = DEFAULT_TEXT_WIDTH): string[] { const lines: string[] = []; for (const paragraph of text.split(/\n/)) { if (paragraph.length === 0) { diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 70578094..4aa5014a 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -220,7 +220,7 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - "- npm:express@5.2.1\n Indexing: code | Ready now: versions 5.2.1", + "- npm:express@5.2.1\n Indexing: code | Available now: versions 5.2.1", " Using: 5.1.0 while 5.2.1 indexes", ), ); @@ -246,7 +246,7 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - " Indexing: code | Ready now: versions 5.2.1", + " Indexing: code | Available now: versions 5.2.1", `${section} 0/1 targets`, ), ); @@ -466,7 +466,7 @@ function smokeResponse( return textResult( "Indexing - no result snapshot yet\n\n" + "- npm:express@5.2.1\n" + - " Indexing: code | Ready now: versions 5.2.1\n\n" + + " Indexing: code | Available now: versions 5.2.1\n\n" + "Search smoke-ref | 0/1 target ready\n" + 'Next: search_status search_ref="smoke-ref" wait_timeout_ms=20000', ); diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 28720022..c14487d4 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -243,7 +243,7 @@ function assertSearchDefaultText(text: string, context: string): void { ); const hasReadinessText = lines.some((line) => - /^ {2}(?! {2}).*(?:Indexing|Searched|Ready now|Unavailable|Using|Status):/.test( + /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( line, ), ); diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index 104e39a4..9dd93f07 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -656,7 +656,7 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("- npm:express@4.18.2"); expect(text).toContain("Using: 4.18.2 (older snapshot)"); - expect(text).toContain("Ready now: versions"); + expect(text).toContain("Available now: versions"); expect(text).toContain("4.18.2"); expect(text).not.toContain("ref_resolution_deferred"); }); @@ -807,7 +807,7 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("- npm:express latest"); - expect(text).toContain("Ready now: versions 4.18.2, refs main"); + expect(text).toContain("Available now: versions 4.18.2, refs main"); expect(text).toContain( 'Next: search_status search_ref="ref-alternatives" wait_timeout_ms=20000', ); diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index e67d623d..12836fd0 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -523,9 +523,9 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("No results returned"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch(/Searched:\s+repository\s+docs/); expect(output).toMatch( - /Ready now: expressjs\.com\/en\/guide docs \(not searched;\s+120 pages; partial\)/, + /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Documentation sources:"); expect(output).not.toContain("Documentation corpora"); @@ -564,8 +564,8 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("No results returned"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository docs/); - expect(output).toContain("Ready now: expressjs.com/en/guide docs"); + expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toContain("Available now: expressjs.com/en/guide docs"); expect(output).not.toContain("Do not repeat"); consoleSpy.mockRestore(); }); @@ -1086,9 +1086,8 @@ describe("searchAction", () => { "Indexing - no results yet", "", "- npm:n8n -> 2.36.7", - " Indexing: code, repository docs | Ready now: n8n.io docs (not searched;", - " 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD,", - " master", + " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages;", + " capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", "", "Search n8n-search-ref | 0/1 target ready", "Next: githits search-status n8n-search-ref --wait 20", @@ -1128,9 +1127,9 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("Indexing - no results yet"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch(/Searched:\s+repository\s+docs/); expect(output).toMatch( - /Ready now: expressjs\.com\/en\/guide docs \(not searched;\s+120 pages; partial\)/, + /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Evidence may change."); expect(output).toContain("githits search-status search-ref-docs"); @@ -1577,7 +1576,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("- github:expressjs/express#refs/heads/master"); expect(output).toContain("Using: refs/heads/master (older snapshot)"); - expect(output).toMatch(/Ready now:\s+refs master/); + expect(output).toMatch(/Available now:\s+refs master/); expect(output).not.toContain("Evidence:"); expect(output).not.toContain("Indexed alternatives:"); expect(output).not.toContain("Next: githits search-status"); @@ -2171,7 +2170,7 @@ describe("searchStatusAction", () => { expect(output).toContain( "- github:expressjs/express#refs/heads/master -> master", ); - expect(output).toContain("Status: indexing | Ready now: refs master"); + expect(output).toContain("Status: indexing | Available now: refs master"); expect(output).toContain("Search search-ref-123 | 0/1 target ready"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", @@ -2426,9 +2425,9 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("No results returned"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch(/Searched:\s+repository\s+docs/); expect(output).toMatch( - /Ready now: expressjs\.com\/en\/guide docs \(not searched;\s+120 pages; partial\)/, + /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Evidence may change."); expect( @@ -2466,7 +2465,7 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("No results returned"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository docs/); + expect(output).toMatch(/Searched:\s+repository\s+docs/); expect(output).toContain("Unavailable: expressjs.com/en/guide docs"); consoleSpy.mockRestore(); }); diff --git a/src/commands/search.ts b/src/commands/search.ts index 1f7ac2c9..69cbb17d 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -422,5 +422,6 @@ function cliSearchTextOptions(): UnifiedSearchTextOptions { return { useColors: shouldUseColors(), actionSyntax: "cli", + width: process.stdout.columns, }; } From 256a6dbea52636ea93006232a9d68d43f2134d0c Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 08:53:50 +0300 Subject: [PATCH 36/46] fix: preserve useful indexed refs Keep named refs ahead of unmapped commit identifiers in bounded search alternatives and align CLI smoke validation and parity documentation with the Available now wording and terminal-aware wrapping. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/mcp-cli-parity.md | 5 +++-- .../src/shared/unified-search-presentation.test.ts | 8 +++++++- .../mcp/src/shared/unified-search-presentation.ts | 2 +- scripts/cli-smoke.ts | 2 +- scripts/smoke-scripts.test.ts | 14 ++++++++------ 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 09e2c762..03670b37 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -236,7 +236,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The order is outcome, target blocks with grouped readiness and usable alternatives, warnings/results, an optional session summary, and one positive next action. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and substitutes directly executable CLI actions (`githits search-status`, `githits code read`, `githits docs read`, and `githits code grep`) for MCP tool-call syntax. Removing ANSI from CLI output leaves the same text contract apart from those supplied commands. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The order is outcome, target blocks with grouped readiness and usable alternatives, warnings/results, an optional session summary, and one positive next action. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and substitutes directly executable CLI actions (`githits search-status`, `githits code read`, `githits docs read`, and `githits code grep`) for MCP tool-call syntax. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those supplied commands; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index cfd97157..00dea1b9 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -272,8 +272,9 @@ MCP renders `Next: search_status search_ref=... wait_timeout_ms=...`; CLI render action use the same reference when both are present; raw diagnostic fields are never rendered. Search-result follow-ups likewise use `code_read` / `docs_read` in MCP and `githits code read` / `githits docs read` in -CLI. ANSI-stripped CLI output is structurally identical to no-color MCP text -apart from those supplied command dialects. +CLI. ANSI-stripped CLI output shares the same hierarchy and wording as no-color +MCP text apart from those supplied command dialects; line breaks can differ +because CLI uses the terminal width while MCP uses the 80-column default. CLI `--json` output and MCP `format: "json"` output remain the structured parity boundary: every diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 2d274591..76609a25 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -678,6 +678,8 @@ describe("projectUnifiedSearchPresentation", () => { it("classifies versionless available-version entries as refs", () => { const sha = "df0abc9333a3398b97b71f6ea7cd77d5ea3e9f97"; + const secondSha = "1b51edac7c5f2844e23602164a52643bb625993a"; + const thirdSha = "4687d59a28ca41c4a9c06e69b68e8d3300000000"; const presentation = projectUnifiedSearchPresentation( completed({ results: [], @@ -687,6 +689,8 @@ describe("projectUnifiedSearchPresentation", () => { targetResolution: { availableVersions: [ { ref: sha }, + { ref: secondSha }, + { ref: thirdSha }, { version: "4.0.0", ref: "v4.0.0" }, ], availableRefs: [{ ref: "master" }], @@ -700,9 +704,11 @@ describe("projectUnifiedSearchPresentation", () => { { version: "4.0.0", ref: "v4.0.0" }, ]); expect(presentation.alternatives[0]?.refs).toEqual([ - { ref: sha }, { ref: "master" }, + { ref: sha }, + { ref: secondSha }, ]); + expect(presentation.alternatives[0]?.refsRemaining).toBe(1); }); it.each([ diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 098d4631..6f922049 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1098,8 +1098,8 @@ function boundedAlternatives( versions.filter((alternative) => alternative.version !== undefined), ); const refFacts = bounded([ - ...versions.filter((alternative) => alternative.version === undefined), ...refs, + ...versions.filter((alternative) => alternative.version === undefined), ]); const suggestedRefFacts = bounded(suggestedRefs); return { diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 5ff69fd2..48f6a2d7 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -433,7 +433,7 @@ export function assertSearchTerminalText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:Indexing|Searched|Ready now|Unavailable|Using|Status):/.test( + /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( line, ), ); diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index f8f8fbdf..bea3d1aa 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -28,7 +28,7 @@ describe("CLI search smoke contract", () => { const valid = `Indexing - no results yet - npm:n8n -> 2.36.7 - Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master + Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master Search smoke-ref | 0/1 target ready Next: githits search-status smoke-ref --wait 20`; @@ -51,7 +51,9 @@ More hits available. Pass --offset 10 or --limit N to widen.`; it("accepts outcome-first text with CLI-native actions", () => { expect(valid.split("\n")[0]).toBe("Indexing - no results yet"); expect(valid).toContain("- npm:n8n -> 2.36.7"); - expect(valid).toContain(" Indexing: code, repository docs | Ready now:"); + expect(valid).toContain( + " Indexing: code, repository docs | Available now:", + ); expect(valid).toContain("Search smoke-ref | 0/1 target ready"); expect(valid).toContain("Next: githits search-status smoke-ref --wait 20"); expect(() => assertSearchTerminalText(valid, "search")).not.toThrow(); @@ -117,7 +119,7 @@ More hits available. Pass --offset 10 or --limit N to widen.`; expect(() => assertSearchTerminalText( valid.replace( - "- npm:n8n -> 2.36.7\n Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", + "- npm:n8n -> 2.36.7\n Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", " Using: 2.26.9 while 2.36.7 indexes", ), "search", @@ -176,8 +178,8 @@ More hits available. Pass --offset 10 or --limit N to widen.`; expect(() => assertSearchTerminalText( valid.replace( - " Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", - " Ready now: versions 2.36.7", + " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", + " Available now: versions 2.36.7", ), "search", ), @@ -197,7 +199,7 @@ More hits available. Pass --offset 10 or --limit N to widen.`; ["search_ref=payload", "MCP search_ref syntax leaked into CLI output"], ])("rejects target-detail diagnostic %s", (diagnostic, message) => { const readinessLine = - " Indexing: code, repository docs | Ready now: n8n.io docs (not searched; 1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master"; + " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master"; const targetDetail = valid.replace(readinessLine, ` ${diagnostic}`); expect(() => assertSearchTerminalText(targetDetail, "search")).toThrow( From 8f05ccac11c9243c518a288017d7e02570eca3b7 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 09:36:53 +0300 Subject: [PATCH 37/46] fix: compact completed search results Keep CLI and MCP text-v1 on one shared formatter while restoring ranked, human-readable result anatomy. Collapse ordinary completed provenance and pagination without changing JSON locators or readiness detail. --- changes/search-output-hierarchy.changed.md | 2 +- docs/implementation/cli-commands.md | 2 +- docs/implementation/tools.md | 37 +- .../shared/unified-search-status-text.test.ts | 2 +- .../src/shared/unified-search-text.test.ts | 159 ++++++- .../mcp/src/shared/unified-search-text.ts | 443 +++++++++++++----- packages/mcp/src/smoke-test.test.ts | 75 +-- packages/mcp/src/smoke-test.ts | 65 +-- scripts/cli-smoke.ts | 70 ++- scripts/smoke-scripts.test.ts | 68 +-- src/commands/search.test.ts | 43 +- 11 files changed, 665 insertions(+), 301 deletions(-) diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md index cdcfe1a5..a73d5841 100644 --- a/changes/search-output-hierarchy.changed.md +++ b/changes/search-output-hierarchy.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with target-grouped readiness, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance. +- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with compact completed-result headlines, numbered human locators, source provenance, target-grouped readiness when trust facts require it, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 03670b37..927b2d80 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -236,7 +236,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The order is outcome, target blocks with grouped readiness and usable alternatives, warnings/results, an optional session summary, and one positive next action. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and substitutes directly executable CLI actions (`githits search-status`, `githits code read`, `githits docs read`, and `githits code grep`) for MCP tool-call syntax. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those supplied commands; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Hits remain numbered and use compact human locators such as `[1] repo doc · npm:express@5.2.1 · History.md:169-179` or `[2] docs · router.use()` followed by a direct URL. Executable read commands and opaque page IDs stay in JSON locators, not default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index d8f83cf1..a646a41d 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -297,7 +297,7 @@ The `hint` field is emitted only when the cap *actually truncated* the response **In-place evolution.** `text-v1` names the compact line-oriented representation; it is not an exact-prose compatibility boundary. Search and `search_status` may tighten human/agent copy in place as long as their structural lifecycle, ordering, action, and hit-anatomy invariants remain covered by tests (`packages/mcp/src/shared/unified-search-text.test.ts`, `packages/mcp/src/tools/search-status.test.ts`). JSON is the stable structured boundary for programmatic callers. Other text-v1 renderers retain their own contracts and are not changed by the search presentation work. -**ASCII-only.** Separators are ` | `; ellipsis is `...`; no box-drawing or Latin-1 punctuation. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — ASCII keeps it predictable. +**Compact punctuation.** Separators are ` | ` and hit fields use ` · `; ellipsis is `...`; no box-drawing or decorative punctuation. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — the small fixed vocabulary keeps it predictable. **Example-search anatomy.** `get_example` text mode returns markdown directly, followed by `solution_id: ` when the REST response includes an app URL. This avoids JSON-wrapped markdown while preserving the `feedback` workflow. `search_language` text mode returns one match per line as `name (Display Name) aliases: a, b`; agents should pass the `name` value to `get_example.language`. @@ -310,7 +310,7 @@ anatomy, and ordering. Callers supply only ANSI enablement and surface-native action syntax. The order is: 1. outcome headline; -2. target blocks, each with identity plus grouped readiness and usable alternatives; +2. one compact `Sources:` row for ordinary completed current results, or target blocks with identity plus grouped readiness and usable alternatives when trust facts require them; 3. warnings and results; 4. an optional session summary; and 5. one positive next action, when applicable. @@ -352,20 +352,31 @@ The representative CLI n8n example is maintained in **Hit anatomy within unified search text-v1:** ``` -[1] - - - - +[1] repo doc · · + + <summary line 1> + <summary line 2 (wrapped at output width)> [blank] -[2] ... -[blank] -More hits available. Pass offset=N for the next page or limit=N to widen. +[2] docs · <title> + https://<source-url> + <summary, when informative> ``` -`<type>` compacts to `code` / `symbol` / `docs` / `repo-docs`. `<locator-line>` is a ready-to-call follow-up when possible. MCP uses `code_read target="npm:pkg@version" path="..." start_line=N end_line=M` or `docs_read page_id="..."`; CLI uses the equivalent `githits code read ... --lines N-M` or `githits docs read ...`. If a code/symbol hit lacks a file path, text mode prints `follow-up unavailable: missing filePath` rather than fabricating a path. -Pagination follows the same dialect rule: MCP uses `offset=N` / `limit=N`, while -CLI uses `--offset N` / `--limit N`. +Hit headers are numbered so ranked results can be referenced as `[1]` through +`[N]`. Types compact to `repo doc`, `docs`, `code`, and `symbol`; repository +and code hits include their target plus a non-empty file location when one is +available. Documentation hits put a direct HTTP(S) source URL in the body. +Executable `docs_read` / `code_read` commands, opaque page IDs, qualified +internal IDs, and kind/category tails are omitted from default text; JSON keeps +the full locator and follow-up fields unchanged. A summary's first line is +omitted when it repeats the title after removing Markdown heading markers, as +is an immediately following setext underline. Source indentation is retained +when summaries wrap, with a consistent two-space hit-body indent. + +Completed result headlines combine count, type breakdown, and pagination when +known, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. +When more results exist without a next offset, the final field is +`more available`. Pagination is not repeated as a bottom paragraph. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 8d6b06a5..28e6edce 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -58,7 +58,7 @@ describe("renderUnifiedSearchStatusText", () => { expect(firstLine(text)).toBe( "Indexing continues - 1 interim result returned", ); - expect(text).toContain("[1] express/routing npm:express docs"); + expect(text).toContain("[1] docs · Routing"); expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 3e8d8fa4..a2d9c3e3 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -172,19 +172,136 @@ function firstLine(text: string): string { } describe("renderUnifiedSearchSuccess", () => { + it("renders completed Express results as compact ranked source-backed hits", () => { + const repoSummary = + "5.0.0-alpha.4 / 2017-03-01\n" + + "==========================\n" + + " * remove:\n" + + " - Remove Express 3.x middleware error stubs\n" + + " * deps: router@~1.3.0\n" + + ' - Add `next("router")` to exit from router'; + const results: UnifiedSearchHitPayload[] = [ + ...Array.from({ length: 5 }, (_, index) => + index === 0 + ? { + type: "repository_doc", + target: "npm:express@5.2.1", + title: "5.0.0-alpha.4 / 2017-03-01", + summary: repoSummary, + locator: { + registry: "npm", + packageName: "express", + version: "5.2.1", + filePath: "History.md", + startLine: 169, + endLine: 179, + }, + } + : { + type: "repository_doc", + target: "npm:express@5.2.1", + title: `History entry ${index}`, + summary: `History entry ${index} details`, + locator: { + filePath: "History.md", + startLine: 180 + index, + endLine: 185 + index, + }, + }, + ), + ...Array.from({ length: 5 }, (_, index) => ({ + type: "documentation_page", + target: "npm:express@5.2.1", + title: index === 0 ? "router.use()" : `Router docs ${index}`, + summary: + index === 0 ? "### router.use()" : `Router docs ${index} details`, + locator: { + pageId: `opaque-page-${index}`, + sourceUrl: `https://expressjs.com/en/api/router/${index}`, + }, + })), + ]; + const text = renderUnifiedSearchSuccess( + completed(results, { + hasMore: true, + nextOffset: 10, + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:express@5.2.1", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 5, + siteKey: "expressjs.com", + siteUrl: "https://expressjs.com", + }, + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 5, + repositoryUrl: "https://github.com/expressjs/express", + commitSha: "dbac741a49a5a64336b70c06e85c2e2706e36336", + }, + ], + }), + ], + }), + ); + + expect(text.split("\n")[0]).toBe( + "10 results | 5 repo docs, 5 docs pages | next_offset=10", + ); + expect(text).toContain( + "Sources: expressjs.com; expressjs/express@dbac741a", + ); + expect(text).toContain( + "[1] repo doc · npm:express@5.2.1 · History.md:169-179", + ); + expect(text).toContain( + "[6] docs · router.use()\n https://expressjs.com/en/api/router/0", + ); + expect(text).toContain(" * remove:"); + expect(text).toContain(" - Remove Express 3.x middleware error stubs"); + expect(text).not.toContain("githits docs read"); + expect(text).not.toContain("docs_read"); + expect(text).not.toContain("opaque-page"); + expect(text).not.toContain("### router.use()"); + expect(text.match(/next_offset=10/g)).toHaveLength(1); + expect(text.length).toBeLessThan(3459); + }); + it("starts completed hits with the outcome and preserves hit anatomy", () => { const text = renderUnifiedSearchSuccess(completed([codeHit()])); expect(firstLine(text)).toContain("1 result"); expect(firstLine(text)).not.toContain("search |"); - expect(text).toContain("[1] cline/cline@v3.4.2 code"); expect(text).toContain( - ' code_read target="npm:cline@v3.4.2" path="src/integrations/diff/strategies/multi-search-replace.ts" start_line=142 end_line=156 function', + "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); - expect(text).toContain(" applyEdit"); + expect(text).toContain(" applyEdit"); expect(text).not.toContain("searchRef="); }); + it("uses singular labels for one repository doc and one docs page", () => { + const repoText = renderUnifiedSearchSuccess( + completed([ + { + type: "repository_doc", + target: "npm:express@5.2.1", + title: "History.md", + summary: "Release history", + locator: { filePath: "History.md", startLine: 169, endLine: 179 }, + }, + ]), + ); + const docsText = renderUnifiedSearchSuccess(completed([docsHit()])); + + expect(firstLine(repoText)).toBe("1 result | 1 repo doc"); + expect(firstLine(docsText)).toBe("1 result | 1 docs page"); + }); + it("renders completed empty evidence once and uses model pivots", () => { const text = renderUnifiedSearchSuccess( completed([], { @@ -289,7 +406,7 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(code).toContain( - "githits code read 'npm:cline@v3.4.2' 'src/integrations/diff/strategies/multi-search-replace.ts' --lines 142-156", + "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); const repositoryCode = renderUnifiedSearchSuccess( @@ -308,13 +425,15 @@ describe("renderUnifiedSearchSuccess", () => { { actionSyntax: "cli" }, ); expect(repositoryCode).toContain( - "githits code read --repo-url 'https://github.com/cline/cline' --git-ref 'main' 'src/index.ts' --lines 10-20", + "[1] code · github:cline/cline#main · src/index.ts:10-20", ); const docs = renderUnifiedSearchSuccess(completed([docsHit()]), { actionSyntax: "cli", }); - expect(docs).toContain("githits docs read 'aider/edit-formats'"); + expect(docs).toContain( + "[1] docs · Edit Formats\n https://aider.chat/docs/more/edit-formats.html", + ); const empty = renderUnifiedSearchSuccess( completed([], { @@ -728,7 +847,7 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); - expect(firstLine(text)).toBe("2 results"); + expect(firstLine(text)).toBe("2 results | 2 code"); expect(firstLine(text)).not.toContain(" from "); }); @@ -873,7 +992,7 @@ describe("renderUnifiedSearchSuccess", () => { ), ); - expect(firstLine(text)).toBe("1 result"); + expect(firstLine(text)).toBe("1 result | 1 code"); expect(text).toContain("- npm:express latest -> 5.2.1"); expect(text.match(/Using:/g)).toHaveLength(1); expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); @@ -892,7 +1011,7 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); - expect(firstLine(text)).toBe("1 result"); + expect(firstLine(text)).toBe("1 result | 1 code"); expect(text).toContain("- npm:express latest -> 5.2.1"); expect(text.match(/Using:/g)).toHaveLength(1); expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); @@ -1161,16 +1280,16 @@ describe("renderUnifiedSearchSuccess", () => { const cliText = renderUnifiedSearchSuccess(payload, { actionSyntax: "cli", }); - expect(text).toContain("[1] cline/cline@v3.4.2"); - expect(text).toContain("[2] aider/edit-formats aider-AI/aider"); expect(text).toContain( - "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", + "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); - expect(text).toContain("More hits available. Pass offset=10"); - expect(cliText).toContain( - "More hits available. Pass --offset 10 or --limit N to widen.", + expect(text).toContain("[2] docs · Edit Formats"); + expect(text).toContain( + "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", ); - expect(cliText).not.toContain("Pass offset=10"); + expect(text).toContain("next_offset=10"); + expect(cliText).toContain("next_offset=10"); + expect(cliText).not.toContain("More hits available"); expect(text).not.toContain("v5.0.0"); expect(text).not.toContain("dev"); }); @@ -1184,7 +1303,7 @@ describe("renderUnifiedSearchSuccess", () => { }); expect(presentation.hasMore).toBe(true); - expect(text).toContain("More hits available. Pass offset=10"); + expect(text).toContain("No results returned"); }); it("wraps bounded summaries without splitting exact tokens", () => { @@ -1289,7 +1408,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.match(/120 pages/g)).toHaveLength(1); }); - it("retains ASCII output and wraps long summaries", () => { + it("wraps long summaries", () => { const text = renderUnifiedSearchSuccess( completed([ codeHit({ @@ -1298,10 +1417,8 @@ describe("renderUnifiedSearchSuccess", () => { }), ]), ); - expect(text).not.toMatch(/[·…—–]/); for (const line of text.split("\n")) { - if (!line.includes("code_read ")) - expect(line.length).toBeLessThanOrEqual(82); + if (!line.startsWith("[")) expect(line.length).toBeLessThanOrEqual(82); } }); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index dc0578b1..ae69877b 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -6,9 +6,8 @@ * format — programmatic / parity callers opt into the structured * JSON envelope by passing `format: "json"`. * - * ASCII-only output — separators tokenize cleanly across BPE - * variants, and there are no Unicode characters that require - * client-side escaping. + * Compact punctuation and line-oriented output keep the response easy to scan + * across terminal and agent clients. * * `text-v1` names the compact representation, not an exact-prose compatibility * boundary. Its lifecycle, ordering, action, and hit-anatomy invariants are @@ -18,7 +17,6 @@ import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { colors, dim, highlight, highlightRanges } from "./colors.js"; -import { buildSearchHitFollowUpCommand } from "./follow-up-command-text.js"; import { projectUnifiedSearchPresentation, targetDisplayFamilyKey, @@ -27,6 +25,7 @@ import { type UnifiedSearchPresentation, type UnifiedSearchSourceEntry, type UnifiedSearchSourceGroup, + type UnifiedSearchSourceKind, type UnifiedSearchTargetGroup, type UnifiedSearchTrustLimit, type UnifiedSearchWarning, @@ -79,7 +78,12 @@ export function renderUnifiedSearchPresentationText( ): string { const settings = normalizeTextOptions(options); const lines: string[] = [ - formatPresentationOutcome(presentation, result.results, settings), + formatPresentationOutcome( + presentation, + result.results, + result.nextOffset, + settings, + ), ]; appendPresentationContext(lines, presentation, settings); @@ -89,7 +93,6 @@ export function renderUnifiedSearchPresentationText( } const hasPostResultBlock = - presentation.hasMore || presentation.searchRef !== undefined || presentation.progress !== undefined || presentation.action.kind !== "none"; @@ -101,34 +104,11 @@ export function renderUnifiedSearchPresentationText( lines.push(""); } - if (presentation.hasMore) { - if (lines[lines.length - 1] !== "") lines.push(""); - const nextOffsetHint = formatPaginationHint( - result.nextOffset, - settings.actionSyntax, - ); - lines.push(nextOffsetHint); - } - appendPresentationSession(lines, presentation, settings); appendPresentationAction(lines, presentation, settings); return lines.join("\n"); } -function formatPaginationHint( - nextOffset: number | undefined, - actionSyntax: "mcp" | "cli", -): string { - if (actionSyntax === "cli") { - return typeof nextOffset === "number" - ? `More hits available. Pass --offset ${nextOffset} or --limit N to widen.` - : "More hits available. Pass --limit N to widen."; - } - return typeof nextOffset === "number" - ? `More hits available. Pass offset=${nextOffset} or limit=N to widen.` - : "More hits available. Pass limit=N to widen."; -} - interface NormalizedTextOptions { useColors: boolean; actionSyntax: "mcp" | "cli"; @@ -151,6 +131,7 @@ function normalizeTextOptions( function formatPresentationOutcome( presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], + nextOffset: number | undefined, options: NormalizedTextOptions, ): string { const target = presentationTarget(presentation, results); @@ -186,7 +167,12 @@ function formatPresentationOutcome( if (presentation.lifecycle.kind === "completed") { return styleOutcome( count > 0 - ? `${countLabel}${target ? ` from ${target}` : ""}` + ? formatCompletedResultsHeadline( + presentation, + results, + nextOffset, + countLabel, + ) : `No results returned${target ? ` from ${target}` : ""}`, presentation, options.useColors, @@ -214,6 +200,59 @@ function formatPresentationOutcome( ); } +function formatCompletedResultsHeadline( + presentation: UnifiedSearchPresentation, + results: UnifiedSearchHitPayload[], + nextOffset: number | undefined, + countLabel: string, +): string { + const parts = [countLabel]; + const breakdown = formatResultBreakdown(results); + if (breakdown) parts.push(breakdown); + if (presentation.hasMore) { + parts.push( + typeof nextOffset === "number" + ? `next_offset=${nextOffset}` + : "more available", + ); + } + return parts.join(SEP); +} + +function formatResultBreakdown(results: UnifiedSearchHitPayload[]): string { + const counts = new Map<string, number>(); + for (const result of results) { + const label = resultBreakdownLabel(result.type); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return [...counts.entries()] + .map(([label, count]) => `${count} ${resultCountLabel(label, count)}`) + .join(", "); +} + +function resultCountLabel(label: string, count: number): string { + if (count !== 1) return label; + if (label === "repo docs") return "repo doc"; + if (label === "docs pages") return "docs page"; + if (label === "symbols") return "symbol"; + return label; +} + +function resultBreakdownLabel(type: string): string { + switch (type) { + case "repository_doc": + return "repo docs"; + case "documentation_page": + return "docs pages"; + case "repository_symbol": + return "symbols"; + case "repository_code": + return "code"; + default: + return type; + } +} + function styleOutcome( value: string, presentation: UnifiedSearchPresentation, @@ -283,7 +322,10 @@ function appendPresentationContext( presentation: UnifiedSearchPresentation, options: NormalizedTextOptions, ): void { - if (presentation.targetGroups.length > 0) { + if (shouldRenderCompactSources(presentation)) { + lines.push(""); + appendCompactSources(lines, presentation.sources, options); + } else if (presentation.targetGroups.length > 0) { lines.push(""); presentation.targetGroups.forEach((group, index) => { if (index > 0) lines.push(""); @@ -293,6 +335,114 @@ function appendPresentationContext( appendPresentationWarnings(lines, presentation.warnings, options); } +function shouldRenderCompactSources( + presentation: UnifiedSearchPresentation, +): boolean { + if ( + presentation.lifecycle.kind !== "completed" || + presentation.availability.resultCount === 0 || + presentation.sources.length === 0 || + presentation.targetGroups.length === 0 || + presentation.alternatives.length > 0 || + presentation.trustLimits.length > 0 || + presentation.siteSuggestions.length > 0 + ) { + return false; + } + return presentation.targetGroups.every( + (group) => + group.alternatives === undefined && + group.siteSuggestions.length === 0 && + group.trustLimits.length === 0 && + (group.freshnessKind === undefined || + group.freshnessKind === "current") && + group.sources.every((source) => + source.entries.every((entry) => entry.state === "searched"), + ), + ); +} + +function appendCompactSources( + lines: string[], + sources: UnifiedSearchSourceGroup[], + options: NormalizedTextOptions, +): void { + const values = sources + .flatMap((source) => + source.entries.map((entry) => ({ + rank: compactSourceRank(source.kind), + value: formatCompactSource(source.kind, entry), + })), + ) + .filter( + (entry): entry is { rank: number; value: string } => + entry.value.length > 0, + ) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + const unique = [...new Set(values)]; + if (unique.length === 0) return; + lines.push(...wrapText(`Sources: ${unique.join("; ")}`, options.width)); +} + +function compactSourceRank(kind: UnifiedSearchSourceKind): number { + switch (kind) { + case "site_docs": + return 0; + case "repository_docs": + return 1; + case "docs": + return 2; + case "code": + return 3; + } +} + +function formatCompactSource( + kind: UnifiedSearchSourceKind, + entry: UnifiedSearchSourceEntry, +): string { + if (kind === "site_docs") { + return ( + formatDocumentationSiteIdentity(entry.siteUrl) ?? + entry.siteKey ?? + compactTarget(entry.target) + ); + } + if (entry.repositoryUrl) { + return formatRepositoryIdentity(entry.repositoryUrl, entry.commitSha); + } + if (entry.siteUrl) { + return formatDocumentationSiteIdentity(entry.siteUrl) ?? entry.siteUrl; + } + return compactTarget(entry.target); +} + +function formatRepositoryIdentity(url: string, commitSha?: string): string { + let identity = url; + try { + const parsed = new URL(url); + const path = parsed.pathname + .split("/") + .filter(Boolean) + .join("/") + .replace(/\.git$/, ""); + identity = + parsed.host === "github.com" && path ? path : `${parsed.host}/${path}`; + } catch { + identity = url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + } + if (!commitSha) return identity; + return `${identity}@${commitSha.slice(0, 8)}`; +} + +function compactTarget(value: string): string { + return value + .replace(/^site:/, "") + .replace(/^github:/, "") + .replace(/@[^/@#]+$/, ""); +} + function appendPresentationTargetGroup( lines: string[], group: UnifiedSearchTargetGroup, @@ -673,33 +823,31 @@ function appendHit( hit: UnifiedSearchHitPayload, options: NormalizedTextOptions, ): void { - const headerParts: string[] = [ - highlight(formatHitPrimary(hit), options.useColors), - shortType(hit.type), - ]; - lines.push(`[${index}] ${headerParts.join(" ")}`); + lines.push( + `[${index}] ${highlight(formatHitHeader(hit), options.useColors)}`, + ); - const locator = buildLocatorLine(hit, options.actionSyntax); - if (locator) lines.push(` ${locator}`); + if (hit.type === "documentation_page" && isHttpUrl(hit.locator.sourceUrl)) { + lines.push(` ${hit.locator.sourceUrl}`); + } - // Title is suppressed when it's literally the locator we just - // printed; the response builder already drops `qualifiedPath` when - // it equals `title`, so we don't double-check that here. - if (hit.title && hit.title !== hit.locator.filePath) { + const titleIsInHeader = hit.type === "documentation_page"; + if (hit.title && !titleIsInHeader) { lines.push( - ` ${highlightRanges(hit.title, hit.highlights?.title, options.useColors)}`, + ` ${highlightRanges(hit.title, hit.highlights?.title, options.useColors)}`, ); } - if (hit.summary) { - for (const wrapped of wrapHighlightedText( - hit.summary, - hit.highlights?.summary, - Math.max(1, options.width - 4), - options.useColors, - )) { - lines.push(` ${wrapped}`); - } + const summary = prepareSummary(hit.summary, hit.title); + if (summary) { + lines.push( + ...wrapHighlightedText( + summary.text, + shiftHighlightRanges(hit.highlights?.summary, summary.offset), + Math.max(1, options.width - 2), + options.useColors, + ).map((line) => (line.length === 0 ? "" : ` ${line}`)), + ); } } @@ -709,44 +857,85 @@ function wrapHighlightedText( width: number, useColors: boolean, ): string[] { - const wrapped = wrapText(text, width); - if (!useColors || !ranges || ranges.length === 0) return wrapped; - const highlighted: string[] = []; - let cursor = 0; - for (const line of wrapped) { - const start = text.indexOf(line, cursor); - const offset = start >= 0 ? start : cursor; - const localRanges = ranges.map( - ([from, to]) => [from - offset, to - offset] as const, + const output: string[] = []; + let lineOffset = 0; + for (const sourceLine of text.split("\n")) { + const leading = sourceLine.match(/^\s*/)?.[0] ?? ""; + const content = sourceLine.slice(leading.length); + if (content.length === 0) { + output.push(leading); + lineOffset += sourceLine.length + 1; + continue; + } + + const available = Math.max(1, width - leading.length); + let consumed = 0; + while (content.length - consumed > available) { + let breakAt = content.lastIndexOf(" ", consumed + available); + if (breakAt <= consumed) breakAt = consumed + available; + const chunk = content.slice(consumed, breakAt).trimEnd(); + output.push( + highlightWrappedSegment( + leading, + chunk, + lineOffset + leading.length + consumed, + ranges, + useColors, + ), + ); + consumed = breakAt; + while (content[consumed] === " ") consumed += 1; + } + output.push( + highlightWrappedSegment( + leading, + content.slice(consumed), + lineOffset + leading.length + consumed, + ranges, + useColors, + ), ); - highlighted.push(highlightRanges(line, localRanges, true)); - cursor = offset + line.length; + lineOffset += sourceLine.length + 1; } - return highlighted; + return output; } -function formatHitPrimary(hit: UnifiedSearchHitPayload): string { - const loc = hit.locator; - if (hit.type === "documentation_page" && loc.pageId) { - const target = formatDocsPageTarget(loc, hit.target); - return target ? `${loc.pageId} ${target}` : loc.pageId; - } - if (hit.type === "repository_doc" && loc.filePath) { - return `${hit.target} ${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}`; - } - return hit.target; +function highlightWrappedSegment( + leading: string, + content: string, + contentOffset: number, + ranges: ReadonlyArray<readonly [number, number]> | undefined, + useColors: boolean, +): string { + const value = `${leading}${content}`; + if (!useColors || !ranges || ranges.length === 0) return value; + const localRanges = ranges.flatMap(([from, to]) => { + const segmentStart = contentOffset; + const segmentEnd = contentOffset + content.length; + const overlapStart = Math.max(from, segmentStart); + const overlapEnd = Math.min(to, segmentEnd); + return overlapStart < overlapEnd + ? [ + [ + leading.length + overlapStart - segmentStart, + leading.length + overlapEnd - segmentStart, + ] as const, + ] + : []; + }); + return highlightRanges(value, localRanges, true); } -function formatDocsPageTarget( - locator: { - registry?: string; - packageName?: string; - }, - fallbackTarget?: string, -): string { - return locator.registry && locator.packageName - ? `${locator.registry}:${locator.packageName}` - : stripVersionFromTarget(fallbackTarget); +function formatHitHeader(hit: UnifiedSearchHitPayload): string { + const loc = hit.locator; + const type = shortType(hit.type); + if (hit.type === "documentation_page") { + return `${type} · ${hit.title ?? stripVersionFromTarget(hit.target)}`; + } + const location = loc.filePath + ? `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}` + : undefined; + return [type, hit.target, location].filter(Boolean).join(" · "); } function stripVersionFromTarget(value: string | undefined): string { @@ -755,13 +944,6 @@ function stripVersionFromTarget(value: string | undefined): string { return atIndex > 0 ? value.slice(0, atIndex) : value; } -/** - * Compact, agent-friendly type label. - * - * Backend types are uppercase enum-style; the JSON envelope already - * lowercases them. Text mode further compacts to a single token so a - * reader can scan the third column quickly. - */ function shortType(type: string): string { switch (type) { case "repository_code": @@ -771,35 +953,66 @@ function shortType(type: string): string { case "documentation_page": return "docs"; case "repository_doc": - return "repo-docs"; + return "repo doc"; default: return type; } } -function buildLocatorLine( - hit: UnifiedSearchHitPayload, - actionSyntax: "mcp" | "cli", -): string { - const loc = hit.locator; - const followUp = buildSearchHitFollowUpCommand(hit, actionSyntax); - if (followUp) { - const tail: string[] = []; - if (loc.qualifiedPath) tail.push(loc.qualifiedPath); - if (loc.kind) tail.push(loc.kind); - return tail.length > 0 ? `${followUp} ${tail.join(SEP)}` : followUp; - } - if (loc.filePath) { - let line = `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}`; - const tail: string[] = []; - if (loc.qualifiedPath) tail.push(loc.qualifiedPath); - if (loc.kind) tail.push(loc.kind); - if (tail.length > 0) line += ` ${tail.join(SEP)}`; - return line; - } - if (loc.pageId) return `pageId: ${loc.pageId}`; - if (loc.sourceUrl) return loc.sourceUrl; - return ""; +function isHttpUrl(value: string | undefined): value is string { + return ( + value?.startsWith("http://") === true || + value?.startsWith("https://") === true + ); +} + +interface PreparedSummary { + text: string; + offset: number; +} + +function prepareSummary( + summary: string | undefined, + title: string | undefined, +): PreparedSummary | undefined { + if (!summary) return undefined; + const lines = summary.split("\n"); + let offset = 0; + if (title && normalizeHeading(lines[0]) === normalizeHeading(title)) { + offset += (lines[0]?.length ?? 0) + 1; + lines.shift(); + if (lines[0] !== undefined && isSetextUnderline(lines[0])) { + offset += lines[0].length + 1; + lines.shift(); + } + } + const remaining = lines.join("\n"); + const leadingNewline = remaining.match(/^\n+/)?.[0].length ?? 0; + const text = remaining.replace(/^\n+|\n+$/g, ""); + if (text.trim().length === 0) return undefined; + return { text, offset: offset + leadingNewline }; +} + +function shiftHighlightRanges( + ranges: ReadonlyArray<readonly [number, number]> | undefined, + offset: number, +): ReadonlyArray<readonly [number, number]> | undefined { + if (!ranges || offset === 0) return ranges; + return ranges.flatMap(([from, to]) => { + const shiftedFrom = from - offset; + const shiftedTo = to - offset; + return shiftedTo > 0 + ? [[Math.max(0, shiftedFrom), shiftedTo] as const] + : []; + }); +} + +function normalizeHeading(value: string | undefined): string { + return (value ?? "").trim().replace(/^#{1,6}\s+/, ""); +} + +function isSetextUnderline(value: string): boolean { + return /^\s*(?:=+|-+)\s*$/.test(value); } function formatLineRange(start?: number, end?: number): string { diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 4aa5014a..6d5b9d73 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -313,21 +313,38 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] npm:express@5.2.1 code\n" + - ' code_read target="npm:express@5.2.1" path="index.js"\n' + - " Ready: payload text\n" + - " Waiting: payload text\n" + - " Available but not searched: payload text\n" + - " Indexed alternatives: payload text\n" + - " Evidence may change.\n" + - " Do not repeat this payload.\n" + - " Do not poll this payload.\n" + - " Next: payload text\n" + - " Indexing: payload text\n" + - " status: payload text\n" + - " searchRef=payload text\n" + - " indexingRef payload text\n" + - " search_ref=payload text", + "1 result\n\n[1] code · npm:express@5.2.1 · index.js\n" + + " Ready: payload text\n" + + " Waiting: payload text\n" + + " Available but not searched: payload text\n" + + " Indexed alternatives: payload text\n" + + " Evidence may change.\n" + + " Do not repeat this payload.\n" + + " Do not poll this payload.\n" + + " Next: payload text\n" + + " Indexing: payload text\n" + + " status: payload text\n" + + " searchRef=payload text\n" + + " indexingRef payload text\n" + + " search_ref=payload text", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it("keeps multiline hit-body diagnostics opaque after a blank line", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "1 result | 1 code\n\n[1] code · npm:express@5.2.1 · index.js\n" + + " First summary paragraph.\n\n" + + " status: payload text\n" + + " searchRef=payload text\n" + + " indexingRef payload text\n" + + " search_ref=payload text", ); } return smokeResponse(name, args); @@ -340,8 +357,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] npm:express@5.2.1 code\n" + - ' code_read target="npm:express@5.2.1" path="index.js"', + "1 result\n\n[1] code · npm:express@5.2.1 · index.js", ); } return smokeResponse(name, args); @@ -354,8 +370,8 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] docs.example.com/readme documentation\n" + - ' docs_read page_id="docs.example.com/readme"', + "1 result\n\n[1] docs · README\n" + + " https://docs.example.com/readme", ); } return smokeResponse(name, args); @@ -366,21 +382,18 @@ describe("runMcpSmoke", () => { it.each([ [ - "1 result\n\n[1] npm:express@5.2.1 code\n" + - " This payload mentions code_read but has no locator", - ], - [ - "1 result\n\n[1] npm:express@5.2.1 code\n" + - ' code_read target="npm:express@5.2.1"', + "1 result\n\n[1] code · npm:express@5.2.1\n" + + " This payload mentions code_read but has no locator", ], [ - "1 result\n\n[1] docs.example.com/readme documentation\n" + - ' docs_read page_id=""', + "1 result\n\n[1] code · npm:express@5.2.1\n" + + ' code_read target="npm:express@5.2.1"', ], + ["1 result\n\n[1] docs · README\n" + " documentation prose only"], [ - "1 result\n\n[1] npm:express@5.2.1 code\n" + - " ordinary title\n" + - ' code_read target="npm:express@5.2.1" path="index.js"', + "1 result\n\n[1] code · npm:express@5.2.1\n" + + " ordinary title\n" + + ' code_read target="npm:express@5.2.1" path="index.js"', ], ])("rejects incomplete or prose-only hit follow-ups", async (searchText) => { const caller = createCaller(async (name, args) => { @@ -391,7 +404,7 @@ describe("runMcpSmoke", () => { }); await expect(runMcpSmoke(caller)).rejects.toThrow( - "search default: missing ready-to-call result or status follow-up", + "search default: missing usable result locator or status follow-up", ); }); diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index c14487d4..8f5f9f5f 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -182,7 +182,7 @@ export function assertDefaultText( function assertSearchDefaultText(text: string, context: string): void { const lines = text.split("\n"); - const formatterLines = lines.filter((line) => !line.startsWith(" ")); + const formatterLines = searchFormatterLines(lines); const formatterText = formatterLines.join("\n"); const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); @@ -242,14 +242,14 @@ function assertSearchDefaultText(text: string, context: string): void { `${context}: poll policy prose`, ); - const hasReadinessText = lines.some((line) => + const hasReadinessText = formatterLines.some((line) => /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( line, ), ); if (hasReadinessText) { assert( - lines.some((line) => /^-\s+\S/.test(line)), + formatterLines.some((line) => /^-\s+\S/.test(line)), `${context}: readiness details must be grouped under a target`, ); } @@ -303,41 +303,42 @@ function assertSearchDefaultText(text: string, context: string): void { `${context}: CLI command syntax leaked into MCP output`, ); assert( - hasHitLocator(lines, isMcpCodeReadLocator) || - hasHitLocator(lines, isMcpDocsReadLocator) || + hasHumanSearchHitLocator(lines) || lines.some((line) => line.startsWith("Next:")), - `${context}: missing ready-to-call result or status follow-up`, + `${context}: missing usable result locator or status follow-up`, ); } -function hasHitLocator( - lines: string[], - isLocator: (line: string) => boolean, -): boolean { - return lines.some( - (line, index) => - /^\[\d+\]\s/.test(line) && isLocator(lines[index + 1] ?? ""), - ); -} - -function hasNonEmptyMcpArgument(line: string, argument: string): boolean { - return new RegExp( - `(?:^|\\s)${argument}=(?:"[^"]+"|'[^']+'|[^\\s"']+)(?=\\s|$)`, - ).test(line); -} - -function isMcpCodeReadLocator(line: string): boolean { - return ( - /^ {4}code_read\b/.test(line) && - hasNonEmptyMcpArgument(line, "target") && - hasNonEmptyMcpArgument(line, "path") - ); +function hasHumanSearchHitLocator(lines: string[]): boolean { + return lines.some((line, index) => { + const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+·\s+(.+)$/.exec( + line, + ); + if (!match) return false; + if (match[1] === "docs") { + return /^ {2}https?:\/\/\S+/.test(lines[index + 1] ?? ""); + } + const value = match[2]; + if (!value) return false; + const parts = value.split(" · "); + const first = parts[0] ?? ""; + const last = parts[parts.length - 1] ?? ""; + return ( + parts.length >= 2 && first.trim().length > 0 && last.trim().length > 0 + ); + }); } -function isMcpDocsReadLocator(line: string): boolean { - return ( - /^ {4}docs_read\b/.test(line) && hasNonEmptyMcpArgument(line, "page_id") - ); +function searchFormatterLines(lines: string[]): string[] { + let inHit = false; + return lines.filter((line) => { + if (/^\[\d+\]\s/.test(line)) { + inHit = true; + return true; + } + if (inHit && line.length > 0 && !line.startsWith(" ")) inHit = false; + return !inHit; + }); } export function assertJsonResult( diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 48f6a2d7..b931b11b 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -366,7 +366,7 @@ function assertTerminalOutput(result: CommandResult, context: string): string { export function assertSearchTerminalText(text: string, context: string): void { const lines = text.split("\n"); - const formatterLines = lines.filter((line) => !line.startsWith(" ")); + const formatterLines = searchFormatterLines(lines); const formatterText = formatterLines.join("\n"); const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); @@ -456,13 +456,6 @@ export function assertSearchTerminalText(text: string, context: string): void { statusActions.length <= 1, `${context}: expected at most one search-status action`, ); - const paginationLines = formatterLines.filter((line) => - line.startsWith("More hits available."), - ); - assert( - !paginationLines.some((line) => /\b(?:offset|limit)=/.test(line)), - `${context}: MCP pagination syntax leaked into CLI output`, - ); const summaryLines = formatterLines.filter((line) => /^Search\s+\S+\s+\|/.test(line), ); @@ -491,44 +484,41 @@ export function assertSearchTerminalText(text: string, context: string): void { `${context}: MCP search_ref syntax leaked into CLI output`, ); assert( - hasSearchHitLocator(lines, isCliCodeReadLocator) || - hasSearchHitLocator(lines, isCliDocsReadLocator) || - nextLines.length > 0, + hasHumanSearchHitLocator(lines) || nextLines.length > 0, `${context}: missing result follow-up or next action`, ); } -function hasSearchHitLocator( - lines: string[], - isLocator: (line: string) => boolean, -): boolean { - return lines.some( - (line, index) => - /^\[\d+\]\s/.test(line) && isLocator(lines[index + 1] ?? ""), - ); -} - -const CLI_SHELL_ARGUMENT = String.raw`(?:'[^']+'|"[^"]+"|[^\s'"]+)`; -const CLI_POSITIONAL_ARGUMENT = String.raw`(?:'[^']+'|"[^"]+"|(?!-)[^\s'"]+)`; -const CLI_PACKAGE_CODE_READ_LOCATOR = new RegExp( - String.raw`^ {4}githits code read\s+(?!--repo-url\b)${CLI_POSITIONAL_ARGUMENT}\s+${CLI_POSITIONAL_ARGUMENT}(?:\s|$)`, -); -const CLI_REPOSITORY_CODE_READ_LOCATOR = new RegExp( - String.raw`^ {4}githits code read\s+--repo-url\s+${CLI_SHELL_ARGUMENT}(?:\s+--git-ref\s+${CLI_SHELL_ARGUMENT})?\s+${CLI_POSITIONAL_ARGUMENT}(?:\s|$)`, -); -const CLI_DOCS_READ_LOCATOR = new RegExp( - String.raw`^ {4}githits docs read\s+${CLI_POSITIONAL_ARGUMENT}(?:\s|$)`, -); - -function isCliCodeReadLocator(line: string): boolean { - return ( - CLI_PACKAGE_CODE_READ_LOCATOR.test(line) || - CLI_REPOSITORY_CODE_READ_LOCATOR.test(line) - ); +function hasHumanSearchHitLocator(lines: string[]): boolean { + return lines.some((line, index) => { + const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+·\s+(.+)$/.exec( + line, + ); + if (!match) return false; + if (match[1] === "docs") { + return /^ {2}https?:\/\/\S+/.test(lines[index + 1] ?? ""); + } + const value = match[2]; + if (!value) return false; + const parts = value.split(" · "); + const first = parts[0] ?? ""; + const last = parts[parts.length - 1] ?? ""; + return ( + parts.length >= 2 && first.trim().length > 0 && last.trim().length > 0 + ); + }); } -function isCliDocsReadLocator(line: string): boolean { - return CLI_DOCS_READ_LOCATOR.test(line); +function searchFormatterLines(lines: string[]): string[] { + let inHit = false; + return lines.filter((line) => { + if (/^\[\d+\]\s/.test(line)) { + inHit = true; + return true; + } + if (inHit && line.length > 0 && !line.startsWith(" ")) inHit = false; + return !inHit; + }); } function assertJsonOutput(result: CommandResult, context: string): unknown { diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index bea3d1aa..e6033b00 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -38,15 +38,13 @@ Next: githits search-status smoke-ref --wait 20`; Searched: repository docs Next: shorten or broaden query; use githits code grep.`; - const completed = `1 result + const completed = `1 result | 1 code | next_offset=10 -[1] npm:express@5.2.1 code - githits code read 'npm:express@5.2.1' 'lib/application.js' --lines 1-10 -More hits available. Pass --offset 10 or --limit N to widen.`; - const completedDocs = `1 result +[1] code · npm:express@5.2.1 · lib/application.js`; + const completedDocs = `1 result | 1 docs pages -[1] docs.example.com/getting-started docs - githits docs read 'docs.example.com/getting-started' --lines 1-10`; +[1] docs · Getting started + https://docs.example.com/getting-started`; it("accepts outcome-first text with CLI-native actions", () => { expect(valid.split("\n")[0]).toBe("Indexing - no results yet"); @@ -74,7 +72,10 @@ More hits available. Pass --offset 10 or --limit N to widen.`; it.each([ [`Warning: indexing\n${valid}`, "non-outcome text"], [`${completed}\nstatus: indexing`, "lifecycle status"], - [completed.replace("--offset 10", "offset=10"), "MCP pagination syntax"], + [ + completed.replace(" · lib/application.js", ""), + "missing result follow-up", + ], ["1 result from npm:express@5.2.1", "missing result follow-up"], ])("rejects invalid search text", (text, message) => { expect(() => assertSearchTerminalText(text, "search")).toThrow(message); @@ -92,17 +93,15 @@ More hits available. Pass --offset 10 or --limit N to widen.`; it.each([ [ - "1 result\n\n[1] npm:express@5.2.1 code\n This payload mentions githits code read but has no locator", - ], - [ - "1 result\n\n[1] npm:express@5.2.1 code\n githits code read 'npm:express@5.2.1' --lines 1-10", + "1 result\n\n[1] code · npm:express@5.2.1\n This payload mentions githits code read but has no locator", ], [ - "1 result\n\n[1] npm:express@5.2.1 code\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", + "1 result\n\n[1] code · npm:express@5.2.1\n githits code read 'npm:express@5.2.1' --lines 1-10", ], [ - "1 result\n\n[1] docs.example.com docs\n githits docs read --lines 1-10", + "1 result\n\n[1] code · npm:express@5.2.1\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", ], + ["1 result\n\n[1] docs · README\n githits docs read --lines 1-10"], ])("rejects incomplete or prose-only hit follow-ups", (text) => { expect(() => assertSearchTerminalText(text, "search")).toThrow( "missing result follow-up or next action", @@ -210,21 +209,32 @@ More hits available. Pass --offset 10 or --limit N to widen.`; it("ignores formatter-like words and diagnostics in indented hit content", () => { const hitText = `1 result -[1] npm:express@5.2.1 code - githits code read 'npm:express@5.2.1' 'lib/application.js' --lines 1-10 - Ready: payload text - Waiting: payload text - Available but not searched: payload text - Indexed alternatives: payload text - Evidence may change. - Do not repeat this payload. - Do not poll this payload. - Next: payload text - Indexing: payload text - status: payload text - searchRef=payload text - indexingRef payload text - search_ref=payload text`; +[1] code · npm:express@5.2.1 · lib/application.js + Ready: payload text + Waiting: payload text + Available but not searched: payload text + Indexed alternatives: payload text + Evidence may change. + Do not repeat this payload. + Do not poll this payload. + Next: payload text + Indexing: payload text + status: payload text + searchRef=payload text + indexingRef payload text + search_ref=payload text`; + + expect(() => assertSearchTerminalText(hitText, "search")).not.toThrow(); + }); + + it("keeps multiline hit-body diagnostics opaque after a blank line", () => { + const hitText = + "1 result | 1 code\n\n[1] code · npm:express@5.2.1 · index.js\n" + + " First summary paragraph.\n\n" + + " status: payload text\n" + + " searchRef=payload text\n" + + " indexingRef payload text\n" + + " search_ref=payload text"; expect(() => assertSearchTerminalText(hitText, "search")).not.toThrow(); }); diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 12836fd0..b3a97148 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -708,13 +708,13 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("1 result"); + expect(output.split("\n")[0]).toBe("1 result | 1 docs page"); expect(output).toContain("- npm:express@5.1.0"); expect(output).toContain( "Searched: repository docs, expressjs.com/en/guide docs", ); - expect(output).toContain("[1] express/routing npm:express docs"); - expect(output).toContain("githits docs read 'express/routing'"); + expect(output).toContain("[1] docs · Routing"); + expect(output).toContain("https://expressjs.com/en/guide/routing.html"); expect(output).not.toContain("Documentation sources"); expect(output).not.toContain("hits on this page"); expect(output).not.toContain("124 pages"); @@ -936,11 +936,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("1 result from npm:express@4.18.2"); - expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output.split("\n")[0]).toBe("1 result | 1 code"); expect(output).toContain( - "githits code read 'npm:express@4.18.2' 'lib/router/index.js' --lines 42-57", + "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", ); + expect(output).not.toContain("githits code read"); expect(output).toContain("router middleware"); consoleSpy.mockRestore(); }); @@ -1157,7 +1157,9 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); expect(output).toContain("- npm:express@4.18.2"); - expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain( + "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + ); expect(output).toContain("Search ref-deferred | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); @@ -1194,7 +1196,9 @@ describe("searchAction", () => { "FUTURE_SESSION_STATE - 1 result returned", ); expect(output).toContain("- npm:express@4.18.2"); - expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain( + "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + ); expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); @@ -1650,7 +1654,7 @@ describe("searchAction", () => { "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m code", + "\u001b[1m\u001b[36mcode · npm:express@4.18.2 · lib/router/index.js:42-57\u001b[0m", ); } finally { consoleSpy.mockRestore(); @@ -1771,7 +1775,7 @@ describe("searchAction", () => { } }); - it("shows pageId and source info for documentation pages", async () => { + it("shows direct source URLs and hides page IDs for documentation pages", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); if (defaultUnifiedSearchOutcome.state !== "completed") { @@ -1812,8 +1816,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("[1] docs-123 npm:express docs"); - expect(output).toContain("githits docs read 'docs-123'"); + expect(output).toContain("[1] docs · Using Express middleware"); + expect(output).toContain("https://hexdocs.pm/express/getting-started.html"); + expect(output).not.toContain("docs-123"); expect(output).toContain("Using Express middleware"); expect(output).not.toContain("source:"); expect(output).not.toContain("npm:express@4.18.2 [docs page]"); @@ -1860,8 +1865,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("[1] docs-routing docs.example docs"); - expect(output).toContain("githits docs read 'docs-routing'"); + expect(output).toContain("[1] docs · Routing"); + expect(output).toContain("https://docs.example/routing"); expect(output).toContain("Routing"); consoleSpy.mockRestore(); }); @@ -2256,7 +2261,9 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); - expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain( + "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + ); expect(output).toContain("Search ref-deferred | 1/2 targets ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); @@ -2293,7 +2300,9 @@ describe("searchStatusAction", () => { expect(output.split("\n")[0]).toBe( "FUTURE_SESSION_STATE - 1 result returned", ); - expect(output).toContain("[1] npm:express@4.18.2 code"); + expect(output).toContain( + "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + ); expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); @@ -2585,7 +2594,7 @@ describe("searchStatusAction", () => { "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m code", + "\u001b[1m\u001b[36mcode · npm:express@4.18.2 · lib/router/index.js:42-57\u001b[0m", ); } finally { consoleSpy.mockRestore(); From 24ff6dbb7749ff5253b5aaf926e65ebfa0ce2089 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 09:48:39 +0300 Subject: [PATCH 38/46] fix: preserve compact result edge cases Keep pagination on non-final snapshots, attribute multi-target docs, disclose unavailable human locators, and preserve long exact tokens without exposing opaque IDs. --- docs/implementation/tools.md | 7 +- .../src/shared/unified-search-text.test.ts | 83 ++++++++++++- .../mcp/src/shared/unified-search-text.ts | 111 +++++++++--------- packages/mcp/src/smoke-test.test.ts | 14 +++ packages/mcp/src/smoke-test.ts | 4 +- scripts/cli-smoke.ts | 4 +- scripts/smoke-scripts.test.ts | 11 +- 7 files changed, 172 insertions(+), 62 deletions(-) diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index a646a41d..454949d4 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -366,6 +366,9 @@ Hit headers are numbered so ranked results can be referenced as `[1]` through `[N]`. Types compact to `repo doc`, `docs`, `code`, and `symbol`; repository and code hits include their target plus a non-empty file location when one is available. Documentation hits put a direct HTTP(S) source URL in the body. +When the backend has no source URL, the body says `Source URL unavailable` +without exposing the opaque page ID. Repository-backed hits without a file path +end their header with `location unavailable` rather than fabricating a locator. Executable `docs_read` / `code_read` commands, opaque page IDs, qualified internal IDs, and kind/category tails are omitted from default text; JSON keeps the full locator and follow-up fields unchanged. A summary's first line is @@ -373,8 +376,8 @@ omitted when it repeats the title after removing Markdown heading markers, as is an immediately following setext underline. Source indentation is retained when summaries wrap, with a consistent two-space hit-body indent. -Completed result headlines combine count, type breakdown, and pagination when -known, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. +Result headlines combine count, type breakdown when completed, and pagination +when known, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. When more results exist without a next offset, the final field is `more available`. Pagination is not repeated as a bottom paragraph. diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index a2d9c3e3..57909d9c 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -302,6 +302,46 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(docsText)).toBe("1 result | 1 docs page"); }); + it("keeps documentation targets compact unless multiple targets need attribution", () => { + const single = renderUnifiedSearchSuccess(completed([docsHit()])); + expect(single).toContain("[1] docs · Edit Formats"); + expect(single).not.toContain("[1] docs · aider-AI/aider@v0.55.0"); + + const multiple = renderUnifiedSearchSuccess( + completed([ + docsHit(), + docsHit({ + target: "npm:express@5.2.1", + title: "Routing", + locator: { sourceUrl: "https://expressjs.com/en/guide/routing" }, + }), + ]), + ); + expect(multiple).toContain( + "[1] docs · aider-AI/aider@v0.55.0 · Edit Formats", + ); + expect(multiple).toContain("[2] docs · npm:express@5.2.1 · Routing"); + }); + + it("states when a documentation source URL is unavailable without exposing its page ID", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ locator: { pageId: "internal-page-id" } })]), + ); + + expect(text).toContain("[1] docs · Edit Formats\n Source URL unavailable"); + expect(text).not.toContain("internal-page-id"); + }); + + it("marks repository hits whose human location is unavailable", () => { + const text = renderUnifiedSearchSuccess( + completed([codeHit({ locator: {} })]), + ); + + expect(text).toContain( + "[1] code · cline/cline@v3.4.2 · location unavailable", + ); + }); + it("renders completed empty evidence once and uses model pivots", () => { const text = renderUnifiedSearchSuccess( completed([], { @@ -1283,7 +1323,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain( "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); - expect(text).toContain("[2] docs · Edit Formats"); + expect(text).toContain("[2] docs · aider-AI/aider@v0.55.0 · Edit Formats"); expect(text).toContain( "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", ); @@ -1303,7 +1343,36 @@ describe("renderUnifiedSearchSuccess", () => { }); expect(presentation.hasMore).toBe(true); - expect(text).toContain("No results returned"); + expect(text).toContain("No results returned | next_offset=10"); + }); + + it("keeps pagination in active and terminal result headlines", () => { + const active = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + hasMore: true, + nextOffset: 10, + results: [codeHit()], + }), + ); + const terminal = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + completed: false, + hasMore: true, + nextOffset: 10, + results: [codeHit()], + progress: { + status: "DEFERRED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 1, + }, + }), + ); + + expect(firstLine(active)).toContain("next_offset=10"); + expect(firstLine(terminal)).toContain("next_offset=10"); }); it("wraps bounded summaries without splitting exact tokens", () => { @@ -1421,6 +1490,16 @@ describe("renderUnifiedSearchSuccess", () => { if (!line.startsWith("[")) expect(line.length).toBeLessThanOrEqual(82); } }); + + it("does not split unbreakable summary tokens", () => { + const token = `https://example.com/${"segment".repeat(20)}`; + const text = renderUnifiedSearchSuccess( + completed([codeHit({ summary: `Reference ${token} after` })]), + { width: 40 }, + ); + + expect(text).toContain(` ${token}`); + }); }); describe("renderUnifiedSearchError", () => { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index ae69877b..cafad3d5 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -138,87 +138,67 @@ function formatPresentationOutcome( const targetSuffix = target ? ` ${target}` : ""; const count = presentation.availability.resultCount; const countLabel = `${count} result${count === 1 ? "" : "s"}`; + const finish = (value: string): string => + styleOutcome( + appendPagination(value, presentation.hasMore, nextOffset), + presentation, + options.useColors, + ); if (presentation.lifecycle.kind === "active") { const label = activeLifecycleLabel(presentation.lifecycle); if (presentation.availability.kind === "no_snapshot") { - return styleOutcome( - `${label}${targetSuffix} - no result snapshot yet`, - presentation, - options.useColors, - ); + return finish(`${label}${targetSuffix} - no result snapshot yet`); } if (presentation.availability.kind === "empty") { - return styleOutcome( - `${label}${targetSuffix} - no results yet`, - presentation, - options.useColors, - ); + return finish(`${label}${targetSuffix} - no results yet`); } const resultKind = presentation.availability.kind === "partial" ? "partial" : "interim"; - return styleOutcome( + return finish( `${label} continues - ${countLabel.replace("result", `${resultKind} result`)} returned`, - presentation, - options.useColors, ); } if (presentation.lifecycle.kind === "completed") { - return styleOutcome( + return finish( count > 0 - ? formatCompletedResultsHeadline( - presentation, - results, - nextOffset, - countLabel, - ) + ? formatCompletedResultsHeadline(results, countLabel) : `No results returned${target ? ` from ${target}` : ""}`, - presentation, - options.useColors, ); } const status = presentation.lifecycle.status ?? "UNKNOWN"; - if (count > 0) - return styleOutcome( - `${status} - ${countLabel} returned`, - presentation, - options.useColors, - ); + if (count > 0) return finish(`${status} - ${countLabel} returned`); if (presentation.availability.kind === "no_snapshot") { - return styleOutcome( - `${status} - no result snapshot returned`, - presentation, - options.useColors, - ); + return finish(`${status} - no result snapshot returned`); } - return styleOutcome( - `${status} - no results returned`, - presentation, - options.useColors, - ); + return finish(`${status} - no results returned`); } function formatCompletedResultsHeadline( - presentation: UnifiedSearchPresentation, results: UnifiedSearchHitPayload[], - nextOffset: number | undefined, countLabel: string, ): string { const parts = [countLabel]; const breakdown = formatResultBreakdown(results); if (breakdown) parts.push(breakdown); - if (presentation.hasMore) { - parts.push( - typeof nextOffset === "number" - ? `next_offset=${nextOffset}` - : "more available", - ); - } return parts.join(SEP); } +function appendPagination( + value: string, + hasMore: boolean, + nextOffset: number | undefined, +): string { + if (!hasMore) return value; + const field = + typeof nextOffset === "number" + ? `next_offset=${nextOffset}` + : "more available"; + return `${value}${SEP}${field}`; +} + function formatResultBreakdown(results: UnifiedSearchHitPayload[]): string { const counts = new Map<string, number>(); for (const result of results) { @@ -811,9 +791,13 @@ function appendUnifiedSearchHits( hits: UnifiedSearchHitPayload[], options: NormalizedTextOptions, ): void { + const hitTargets = new Set( + hits.map((hit) => hit.requestedTarget ?? hit.target), + ); + const showDocsTarget = hitTargets.size > 1; hits.forEach((hit, idx) => { if (idx > 0) lines.push(""); - appendHit(lines, idx + 1, hit, options); + appendHit(lines, idx + 1, hit, showDocsTarget, options); }); } @@ -821,14 +805,19 @@ function appendHit( lines: string[], index: number, hit: UnifiedSearchHitPayload, + showDocsTarget: boolean, options: NormalizedTextOptions, ): void { lines.push( - `[${index}] ${highlight(formatHitHeader(hit), options.useColors)}`, + `[${index}] ${highlight(formatHitHeader(hit, showDocsTarget), options.useColors)}`, ); - if (hit.type === "documentation_page" && isHttpUrl(hit.locator.sourceUrl)) { - lines.push(` ${hit.locator.sourceUrl}`); + if (hit.type === "documentation_page") { + lines.push( + isHttpUrl(hit.locator.sourceUrl) + ? ` ${hit.locator.sourceUrl}` + : " Source URL unavailable", + ); } const titleIsInHeader = hit.type === "documentation_page"; @@ -872,7 +861,10 @@ function wrapHighlightedText( let consumed = 0; while (content.length - consumed > available) { let breakAt = content.lastIndexOf(" ", consumed + available); - if (breakAt <= consumed) breakAt = consumed + available; + if (breakAt <= consumed) { + breakAt = content.indexOf(" ", consumed + available); + if (breakAt < 0) break; + } const chunk = content.slice(consumed, breakAt).trimEnd(); output.push( highlightWrappedSegment( @@ -926,15 +918,24 @@ function highlightWrappedSegment( return highlightRanges(value, localRanges, true); } -function formatHitHeader(hit: UnifiedSearchHitPayload): string { +function formatHitHeader( + hit: UnifiedSearchHitPayload, + showDocsTarget: boolean, +): string { const loc = hit.locator; const type = shortType(hit.type); if (hit.type === "documentation_page") { - return `${type} · ${hit.title ?? stripVersionFromTarget(hit.target)}`; + return [ + type, + showDocsTarget ? (hit.requestedTarget ?? hit.target) : undefined, + hit.title ?? stripVersionFromTarget(hit.target), + ] + .filter(Boolean) + .join(" · "); } const location = loc.filePath ? `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}` - : undefined; + : "location unavailable"; return [type, hit.target, location].filter(Boolean).join(" · "); } diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 6d5b9d73..42511a4e 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -380,6 +380,20 @@ describe("runMcpSmoke", () => { await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); }); + it("allows documentation hits that disclose a missing source URL", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "1 result | 1 docs page\n\n[1] docs · README\n" + + " Source URL unavailable", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + it.each([ [ "1 result\n\n[1] code · npm:express@5.2.1\n" + diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 8f5f9f5f..e2d9ec1b 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -316,7 +316,9 @@ function hasHumanSearchHitLocator(lines: string[]): boolean { ); if (!match) return false; if (match[1] === "docs") { - return /^ {2}https?:\/\/\S+/.test(lines[index + 1] ?? ""); + return /^(?: {2}https?:\/\/\S+| {2}Source URL unavailable)$/.test( + lines[index + 1] ?? "", + ); } const value = match[2]; if (!value) return false; diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index b931b11b..6b23659f 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -496,7 +496,9 @@ function hasHumanSearchHitLocator(lines: string[]): boolean { ); if (!match) return false; if (match[1] === "docs") { - return /^ {2}https?:\/\/\S+/.test(lines[index + 1] ?? ""); + return /^(?: {2}https?:\/\/\S+| {2}Source URL unavailable)$/.test( + lines[index + 1] ?? "", + ); } const value = match[2]; if (!value) return false; diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index e6033b00..a597ed87 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -41,7 +41,7 @@ Next: shorten or broaden query; use githits code grep.`; const completed = `1 result | 1 code | next_offset=10 [1] code · npm:express@5.2.1 · lib/application.js`; - const completedDocs = `1 result | 1 docs pages + const completedDocs = `1 result | 1 docs page [1] docs · Getting started https://docs.example.com/getting-started`; @@ -91,6 +91,15 @@ Next: shorten or broaden query; use githits code grep.`; ).not.toThrow(); }); + it("accepts documentation hits that disclose a missing source URL", () => { + expect(() => + assertSearchTerminalText( + "1 result | 1 docs page\n\n[1] docs · README\n Source URL unavailable", + "search", + ), + ).not.toThrow(); + }); + it.each([ [ "1 result\n\n[1] code · npm:express@5.2.1\n This payload mentions githits code read but has no locator", From ef74408605c57b7a5e5dc85b4b0ff32caf4d3254 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 10:39:00 +0300 Subject: [PATCH 39/46] fix: use ASCII search hit separators Keep the shared CLI and MCP unified-search text contract readable across clients while preserving Unicode returned by the backend. --- changes/search-output-hierarchy.changed.md | 2 +- docs/implementation/cli-commands.md | 2 +- docs/implementation/tools.md | 6 +- .../shared/unified-search-status-text.test.ts | 2 +- .../src/shared/unified-search-text.test.ts | 59 ++++++++++++++----- .../mcp/src/shared/unified-search-text.ts | 4 +- packages/mcp/src/smoke-test.test.ts | 18 +++--- packages/mcp/src/smoke-test.ts | 4 +- scripts/cli-smoke.ts | 4 +- scripts/smoke-scripts.test.ts | 20 +++---- src/commands/search.test.ts | 20 +++---- 11 files changed, 86 insertions(+), 55 deletions(-) diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md index a73d5841..5eee95d4 100644 --- a/changes/search-output-hierarchy.changed.md +++ b/changes/search-output-hierarchy.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with compact completed-result headlines, numbered human locators, source provenance, target-grouped readiness when trust facts require it, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance. +- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with compact completed-result headlines, numbered human locators, ASCII formatter-authored punctuation, source provenance, target-grouped readiness when trust facts require it, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance while preserving Unicode backend payload text. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 927b2d80..4aa96759 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -236,7 +236,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Hits remain numbered and use compact human locators such as `[1] repo doc · npm:express@5.2.1 · History.md:169-179` or `[2] docs · router.use()` followed by a direct URL. Executable read commands and opaque page IDs stay in JSON locators, not default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search <ref> | <ready>/<total> target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Hits remain numbered and use compact human locators such as `[1] repo doc | npm:express@5.2.1 | History.md:169-179` or `[2] docs | router.use()` followed by a direct URL. Formatter-authored punctuation is ASCII; Unicode in backend payloads passes through unchanged. Executable read commands and opaque page IDs stay in JSON locators, not default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search <ref> | <ready>/<total> target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 454949d4..363b51bb 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -297,7 +297,7 @@ The `hint` field is emitted only when the cap *actually truncated* the response **In-place evolution.** `text-v1` names the compact line-oriented representation; it is not an exact-prose compatibility boundary. Search and `search_status` may tighten human/agent copy in place as long as their structural lifecycle, ordering, action, and hit-anatomy invariants remain covered by tests (`packages/mcp/src/shared/unified-search-text.test.ts`, `packages/mcp/src/tools/search-status.test.ts`). JSON is the stable structured boundary for programmatic callers. Other text-v1 renderers retain their own contracts and are not changed by the search presentation work. -**Compact punctuation.** Separators are ` | ` and hit fields use ` · `; ellipsis is `...`; no box-drawing or decorative punctuation. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — the small fixed vocabulary keeps it predictable. +**Compact punctuation.** Formatter-authored separators are ASCII ` | `; ellipsis is `...`; no box-drawing or decorative punctuation. Unicode in backend payloads (titles, summaries, paths, URLs, and notes) passes through unchanged. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — the small fixed vocabulary keeps it predictable. **Example-search anatomy.** `get_example` text mode returns markdown directly, followed by `solution_id: <id>` when the REST response includes an app URL. This avoids JSON-wrapped markdown while preserving the `feedback` workflow. `search_language` text mode returns one match per line as `name (Display Name) aliases: a, b`; agents should pass the `name` value to `get_example.language`. @@ -352,12 +352,12 @@ The representative CLI n8n example is maintained in **Hit anatomy within unified search text-v1:** ``` -[1] repo doc · <target> · <path:line-range> +[1] repo doc | <target> | <path:line-range> <title> <summary line 1> <summary line 2 (wrapped at output width)> [blank] -[2] docs · <title> +[2] docs | <title> https://<source-url> <summary, when informative> ``` diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 28e6edce..8a3721ae 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -58,7 +58,7 @@ describe("renderUnifiedSearchStatusText", () => { expect(firstLine(text)).toBe( "Indexing continues - 1 interim result returned", ); - expect(text).toContain("[1] docs · Routing"); + expect(text).toContain("[1] docs | Routing"); expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 57909d9c..fa7110f5 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -257,10 +257,10 @@ describe("renderUnifiedSearchSuccess", () => { "Sources: expressjs.com; expressjs/express@dbac741a", ); expect(text).toContain( - "[1] repo doc · npm:express@5.2.1 · History.md:169-179", + "[1] repo doc | npm:express@5.2.1 | History.md:169-179", ); expect(text).toContain( - "[6] docs · router.use()\n https://expressjs.com/en/api/router/0", + "[6] docs | router.use()\n https://expressjs.com/en/api/router/0", ); expect(text).toContain(" * remove:"); expect(text).toContain(" - Remove Express 3.x middleware error stubs"); @@ -278,7 +278,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toContain("1 result"); expect(firstLine(text)).not.toContain("search |"); expect(text).toContain( - "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", + "[1] code | cline/cline@v3.4.2 | src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); expect(text).toContain(" applyEdit"); expect(text).not.toContain("searchRef="); @@ -302,10 +302,41 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(docsText)).toBe("1 result | 1 docs page"); }); + it("uses ASCII separators without changing Unicode payload text", () => { + const text = renderUnifiedSearchSuccess( + completed([ + codeHit({ + title: "Überprüfung", + summary: "Café — маршрутизация", + }), + { + type: "repository_doc", + target: "npm:express@5.2.1", + title: "Résumé", + summary: "naïve release notes", + locator: { filePath: "History.md", startLine: 1 }, + }, + docsHit({ + title: "Документация", + summary: "Café — маршрутизация", + }), + ]), + ); + + expect(text).toContain("[1] code | cline/cline@v3.4.2 |"); + expect(text).toContain("[2] repo doc | npm:express@5.2.1 | History.md:1"); + expect(text).toContain("[3] docs | aider-AI/aider@v0.55.0 | Документация"); + const middleDotSeparator = ` ${String.fromCodePoint(0x00b7)} `; + expect(text).not.toContain(middleDotSeparator); + expect(text).toContain("Überprüfung"); + expect(text).toContain("Café — маршрутизация"); + expect(text).toContain("Résumé"); + }); + it("keeps documentation targets compact unless multiple targets need attribution", () => { const single = renderUnifiedSearchSuccess(completed([docsHit()])); - expect(single).toContain("[1] docs · Edit Formats"); - expect(single).not.toContain("[1] docs · aider-AI/aider@v0.55.0"); + expect(single).toContain("[1] docs | Edit Formats"); + expect(single).not.toContain("[1] docs | aider-AI/aider@v0.55.0"); const multiple = renderUnifiedSearchSuccess( completed([ @@ -318,9 +349,9 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); expect(multiple).toContain( - "[1] docs · aider-AI/aider@v0.55.0 · Edit Formats", + "[1] docs | aider-AI/aider@v0.55.0 | Edit Formats", ); - expect(multiple).toContain("[2] docs · npm:express@5.2.1 · Routing"); + expect(multiple).toContain("[2] docs | npm:express@5.2.1 | Routing"); }); it("states when a documentation source URL is unavailable without exposing its page ID", () => { @@ -328,7 +359,7 @@ describe("renderUnifiedSearchSuccess", () => { completed([docsHit({ locator: { pageId: "internal-page-id" } })]), ); - expect(text).toContain("[1] docs · Edit Formats\n Source URL unavailable"); + expect(text).toContain("[1] docs | Edit Formats\n Source URL unavailable"); expect(text).not.toContain("internal-page-id"); }); @@ -338,7 +369,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "[1] code · cline/cline@v3.4.2 · location unavailable", + "[1] code | cline/cline@v3.4.2 | location unavailable", ); }); @@ -446,7 +477,7 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(code).toContain( - "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", + "[1] code | cline/cline@v3.4.2 | src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); const repositoryCode = renderUnifiedSearchSuccess( @@ -465,14 +496,14 @@ describe("renderUnifiedSearchSuccess", () => { { actionSyntax: "cli" }, ); expect(repositoryCode).toContain( - "[1] code · github:cline/cline#main · src/index.ts:10-20", + "[1] code | github:cline/cline#main | src/index.ts:10-20", ); const docs = renderUnifiedSearchSuccess(completed([docsHit()]), { actionSyntax: "cli", }); expect(docs).toContain( - "[1] docs · Edit Formats\n https://aider.chat/docs/more/edit-formats.html", + "[1] docs | Edit Formats\n https://aider.chat/docs/more/edit-formats.html", ); const empty = renderUnifiedSearchSuccess( @@ -1321,9 +1352,9 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(text).toContain( - "[1] code · cline/cline@v3.4.2 · src/integrations/diff/strategies/multi-search-replace.ts:142-156", + "[1] code | cline/cline@v3.4.2 | src/integrations/diff/strategies/multi-search-replace.ts:142-156", ); - expect(text).toContain("[2] docs · aider-AI/aider@v0.55.0 · Edit Formats"); + expect(text).toContain("[2] docs | aider-AI/aider@v0.55.0 | Edit Formats"); expect(text).toContain( "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", ); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index cafad3d5..9a7e07c5 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -931,12 +931,12 @@ function formatHitHeader( hit.title ?? stripVersionFromTarget(hit.target), ] .filter(Boolean) - .join(" · "); + .join(SEP); } const location = loc.filePath ? `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}` : "location unavailable"; - return [type, hit.target, location].filter(Boolean).join(" · "); + return [type, hit.target, location].filter(Boolean).join(SEP); } function stripVersionFromTarget(value: string | undefined): string { diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 42511a4e..e846c632 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -313,7 +313,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] code · npm:express@5.2.1 · index.js\n" + + "1 result\n\n[1] code | npm:express@5.2.1 | index.js\n" + " Ready: payload text\n" + " Waiting: payload text\n" + " Available but not searched: payload text\n" + @@ -339,7 +339,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result | 1 code\n\n[1] code · npm:express@5.2.1 · index.js\n" + + "1 result | 1 code\n\n[1] code | npm:express@5.2.1 | index.js\n" + " First summary paragraph.\n\n" + " status: payload text\n" + " searchRef=payload text\n" + @@ -357,7 +357,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] code · npm:express@5.2.1 · index.js", + "1 result\n\n[1] code | npm:express@5.2.1 | index.js", ); } return smokeResponse(name, args); @@ -370,7 +370,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] docs · README\n" + + "1 result\n\n[1] docs | README\n" + " https://docs.example.com/readme", ); } @@ -384,7 +384,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result | 1 docs page\n\n[1] docs · README\n" + + "1 result | 1 docs page\n\n[1] docs | README\n" + " Source URL unavailable", ); } @@ -396,16 +396,16 @@ describe("runMcpSmoke", () => { it.each([ [ - "1 result\n\n[1] code · npm:express@5.2.1\n" + + "1 result\n\n[1] code | npm:express@5.2.1\n" + " This payload mentions code_read but has no locator", ], [ - "1 result\n\n[1] code · npm:express@5.2.1\n" + + "1 result\n\n[1] code | npm:express@5.2.1\n" + ' code_read target="npm:express@5.2.1"', ], - ["1 result\n\n[1] docs · README\n" + " documentation prose only"], + ["1 result\n\n[1] docs | README\n" + " documentation prose only"], [ - "1 result\n\n[1] code · npm:express@5.2.1\n" + + "1 result\n\n[1] code | npm:express@5.2.1\n" + " ordinary title\n" + ' code_read target="npm:express@5.2.1" path="index.js"', ], diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index e2d9ec1b..27ae9466 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -311,7 +311,7 @@ function assertSearchDefaultText(text: string, context: string): void { function hasHumanSearchHitLocator(lines: string[]): boolean { return lines.some((line, index) => { - const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+·\s+(.+)$/.exec( + const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+\|\s+(.+)$/.exec( line, ); if (!match) return false; @@ -322,7 +322,7 @@ function hasHumanSearchHitLocator(lines: string[]): boolean { } const value = match[2]; if (!value) return false; - const parts = value.split(" · "); + const parts = value.split(" | "); const first = parts[0] ?? ""; const last = parts[parts.length - 1] ?? ""; return ( diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 6b23659f..cfdce260 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -491,7 +491,7 @@ export function assertSearchTerminalText(text: string, context: string): void { function hasHumanSearchHitLocator(lines: string[]): boolean { return lines.some((line, index) => { - const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+·\s+(.+)$/.exec( + const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+\|\s+(.+)$/.exec( line, ); if (!match) return false; @@ -502,7 +502,7 @@ function hasHumanSearchHitLocator(lines: string[]): boolean { } const value = match[2]; if (!value) return false; - const parts = value.split(" · "); + const parts = value.split(" | "); const first = parts[0] ?? ""; const last = parts[parts.length - 1] ?? ""; return ( diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index a597ed87..3ee5f8f3 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -40,10 +40,10 @@ Next: githits search-status smoke-ref --wait 20`; Next: shorten or broaden query; use githits code grep.`; const completed = `1 result | 1 code | next_offset=10 -[1] code · npm:express@5.2.1 · lib/application.js`; +[1] code | npm:express@5.2.1 | lib/application.js`; const completedDocs = `1 result | 1 docs page -[1] docs · Getting started +[1] docs | Getting started https://docs.example.com/getting-started`; it("accepts outcome-first text with CLI-native actions", () => { @@ -73,7 +73,7 @@ Next: shorten or broaden query; use githits code grep.`; [`Warning: indexing\n${valid}`, "non-outcome text"], [`${completed}\nstatus: indexing`, "lifecycle status"], [ - completed.replace(" · lib/application.js", ""), + completed.replace(" | lib/application.js", ""), "missing result follow-up", ], ["1 result from npm:express@5.2.1", "missing result follow-up"], @@ -94,7 +94,7 @@ Next: shorten or broaden query; use githits code grep.`; it("accepts documentation hits that disclose a missing source URL", () => { expect(() => assertSearchTerminalText( - "1 result | 1 docs page\n\n[1] docs · README\n Source URL unavailable", + "1 result | 1 docs page\n\n[1] docs | README\n Source URL unavailable", "search", ), ).not.toThrow(); @@ -102,15 +102,15 @@ Next: shorten or broaden query; use githits code grep.`; it.each([ [ - "1 result\n\n[1] code · npm:express@5.2.1\n This payload mentions githits code read but has no locator", + "1 result\n\n[1] code | npm:express@5.2.1\n This payload mentions githits code read but has no locator", ], [ - "1 result\n\n[1] code · npm:express@5.2.1\n githits code read 'npm:express@5.2.1' --lines 1-10", + "1 result\n\n[1] code | npm:express@5.2.1\n githits code read 'npm:express@5.2.1' --lines 1-10", ], [ - "1 result\n\n[1] code · npm:express@5.2.1\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", + "1 result\n\n[1] code | npm:express@5.2.1\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", ], - ["1 result\n\n[1] docs · README\n githits docs read --lines 1-10"], + ["1 result\n\n[1] docs | README\n githits docs read --lines 1-10"], ])("rejects incomplete or prose-only hit follow-ups", (text) => { expect(() => assertSearchTerminalText(text, "search")).toThrow( "missing result follow-up or next action", @@ -218,7 +218,7 @@ Next: shorten or broaden query; use githits code grep.`; it("ignores formatter-like words and diagnostics in indented hit content", () => { const hitText = `1 result -[1] code · npm:express@5.2.1 · lib/application.js +[1] code | npm:express@5.2.1 | lib/application.js Ready: payload text Waiting: payload text Available but not searched: payload text @@ -238,7 +238,7 @@ Next: shorten or broaden query; use githits code grep.`; it("keeps multiline hit-body diagnostics opaque after a blank line", () => { const hitText = - "1 result | 1 code\n\n[1] code · npm:express@5.2.1 · index.js\n" + + "1 result | 1 code\n\n[1] code | npm:express@5.2.1 | index.js\n" + " First summary paragraph.\n\n" + " status: payload text\n" + " searchRef=payload text\n" + diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index b3a97148..73d207cb 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -713,7 +713,7 @@ describe("searchAction", () => { expect(output).toContain( "Searched: repository docs, expressjs.com/en/guide docs", ); - expect(output).toContain("[1] docs · Routing"); + expect(output).toContain("[1] docs | Routing"); expect(output).toContain("https://expressjs.com/en/guide/routing.html"); expect(output).not.toContain("Documentation sources"); expect(output).not.toContain("hits on this page"); @@ -938,7 +938,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("1 result | 1 code"); expect(output).toContain( - "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", ); expect(output).not.toContain("githits code read"); expect(output).toContain("router middleware"); @@ -1158,7 +1158,7 @@ describe("searchAction", () => { expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( - "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", ); expect(output).toContain("Search ref-deferred | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); @@ -1197,7 +1197,7 @@ describe("searchAction", () => { ); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( - "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", ); expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); @@ -1654,7 +1654,7 @@ describe("searchAction", () => { "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mcode · npm:express@4.18.2 · lib/router/index.js:42-57\u001b[0m", + "\u001b[1m\u001b[36mcode | npm:express@4.18.2 | lib/router/index.js:42-57\u001b[0m", ); } finally { consoleSpy.mockRestore(); @@ -1816,7 +1816,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("[1] docs · Using Express middleware"); + expect(output).toContain("[1] docs | Using Express middleware"); expect(output).toContain("https://hexdocs.pm/express/getting-started.html"); expect(output).not.toContain("docs-123"); expect(output).toContain("Using Express middleware"); @@ -1865,7 +1865,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("[1] docs · Routing"); + expect(output).toContain("[1] docs | Routing"); expect(output).toContain("https://docs.example/routing"); expect(output).toContain("Routing"); consoleSpy.mockRestore(); @@ -2262,7 +2262,7 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); expect(output).toContain( - "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", ); expect(output).toContain("Search ref-deferred | 1/2 targets ready"); expect(output).toContain("Next: rerun search later."); @@ -2301,7 +2301,7 @@ describe("searchStatusAction", () => { "FUTURE_SESSION_STATE - 1 result returned", ); expect(output).toContain( - "[1] code · npm:express@4.18.2 · lib/router/index.js:42-57", + "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", ); expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); @@ -2594,7 +2594,7 @@ describe("searchStatusAction", () => { "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mcode · npm:express@4.18.2 · lib/router/index.js:42-57\u001b[0m", + "\u001b[1m\u001b[36mcode | npm:express@4.18.2 | lib/router/index.js:42-57\u001b[0m", ); } finally { consoleSpy.mockRestore(); From a0633d046ba85c5dc0991a5c010367f151541108 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 10:44:00 +0300 Subject: [PATCH 40/46] test: preserve Unicode search payload punctuation Keep the formatter separator assertion scoped to exact headers so backend-owned punctuation remains lossless. --- packages/mcp/src/shared/unified-search-text.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index fa7110f5..44a61701 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -306,7 +306,7 @@ describe("renderUnifiedSearchSuccess", () => { const text = renderUnifiedSearchSuccess( completed([ codeHit({ - title: "Überprüfung", + title: "Überprüfung · human review", summary: "Café — маршрутизация", }), { @@ -326,9 +326,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("[1] code | cline/cline@v3.4.2 |"); expect(text).toContain("[2] repo doc | npm:express@5.2.1 | History.md:1"); expect(text).toContain("[3] docs | aider-AI/aider@v0.55.0 | Документация"); - const middleDotSeparator = ` ${String.fromCodePoint(0x00b7)} `; - expect(text).not.toContain(middleDotSeparator); - expect(text).toContain("Überprüfung"); + expect(text).toContain(" Überprüfung · human review"); expect(text).toContain("Café — маршрутизация"); expect(text).toContain("Résumé"); }); From eb578742e0fbe1d992b52f345ffdc0131136c645 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 11:11:45 +0300 Subject: [PATCH 41/46] fix: preserve search follow-up locators Render every unified-search hit with locator-first, type-tagged, title-last anatomy. Keep documentation page IDs and readable source URLs in shared CLI/MCP text so docs_read follow-ups remain actionable, and align smoke validators and durable output docs with the contract. --- changes/search-output-hierarchy.changed.md | 2 +- docs/implementation/cli-commands.md | 2 +- docs/implementation/tools.md | 33 +++--- .../shared/unified-search-status-text.test.ts | 4 +- .../src/shared/unified-search-text.test.ts | 102 +++++++++++++----- .../mcp/src/shared/unified-search-text.ts | 70 +++++------- packages/mcp/src/smoke-test.test.ts | 20 ++-- packages/mcp/src/smoke-test.ts | 36 ++++--- scripts/cli-smoke.ts | 36 ++++--- scripts/smoke-scripts.test.ts | 24 +++-- 10 files changed, 190 insertions(+), 139 deletions(-) diff --git a/changes/search-output-hierarchy.changed.md b/changes/search-output-hierarchy.changed.md index 5eee95d4..23a96235 100644 --- a/changes/search-output-hierarchy.changed.md +++ b/changes/search-output-hierarchy.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with compact completed-result headlines, numbered human locators, ASCII formatter-authored punctuation, source provenance, target-grouped readiness when trust facts require it, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance while preserving Unicode backend payload text. +- **Clarify unified search output** - Add exact partial-result truth to JSON and route `githits` and `@githits/mcp` search/search-status through one outcome-first formatter with compact completed-result headlines, numbered locator-first human hits that retain docs page IDs for `docs_read`, ASCII formatter-authored punctuation, source provenance, target-grouped readiness when trust facts require it, terminal-aware CLI wrapping, concise session/action rows, bounded provenance, ANSI hierarchy, and surface-native continuation guidance while preserving Unicode backend payload text. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 4aa96759..54557b21 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -236,7 +236,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Hits remain numbered and use compact human locators such as `[1] repo doc | npm:express@5.2.1 | History.md:169-179` or `[2] docs | router.use()` followed by a direct URL. Formatter-authored punctuation is ASCII; Unicode in backend payloads passes through unchanged. Executable read commands and opaque page IDs stay in JSON locators, not default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search <ref> | <ready>/<total> target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search <ref> | <ready>/<total> target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 363b51bb..54fc3527 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -352,29 +352,30 @@ The representative CLI n8n example is maintained in **Hit anatomy within unified search text-v1:** ``` -[1] repo doc | <target> | <path:line-range> - <title> +[1] <target> <path:line-range> [repo doc] - <title> <summary line 1> <summary line 2 (wrapped at output width)> [blank] -[2] docs | <title> - https://<source-url> +[2] <page-id> [docs page] <target> - <host/path#anchor> - <title> <summary, when informative> ``` Hit headers are numbered so ranked results can be referenced as `[1]` through -`[N]`. Types compact to `repo doc`, `docs`, `code`, and `symbol`; repository -and code hits include their target plus a non-empty file location when one is -available. Documentation hits put a direct HTTP(S) source URL in the body. -When the backend has no source URL, the body says `Source URL unavailable` -without exposing the opaque page ID. Repository-backed hits without a file path -end their header with `location unavailable` rather than fabricating a locator. -Executable `docs_read` / `code_read` commands, opaque page IDs, qualified -internal IDs, and kind/category tails are omitted from default text; JSON keeps -the full locator and follow-up fields unchanged. A summary's first line is -omitted when it repeats the title after removing Markdown heading markers, as -is an immediately following setext underline. Source indentation is retained -when summaries wrap, with a consistent two-space hit-body indent. +`[N]`. Repository and code hits keep the exact target and file location needed +for `code_read` before a bracketed type tag (`[repo doc]`, `[repo code]`, or +`[repo symbol]`); their free-form title is the final header tail. Documentation +hits keep the actual `page-id` needed for `docs_read`, a stable package target, +human-readable source URL, and title in that order. The docs URL uses +`host/path#anchor` without the protocol; unavailable fields are rendered as +explicit `page ID unavailable`, `target unavailable`, `source URL unavailable`, +or `title unavailable` values. Executable `docs_read` / `code_read` command +lines, qualified non-follow-up internal result IDs, and kind/category tails are +omitted from default text; the documentation page ID remains because it is the +`docs_read` follow-up locator, and JSON keeps the full locator and follow-up +fields unchanged. A summary's first line is omitted when it repeats the title +after removing Markdown heading markers, as is an immediately following +setext underline. Source indentation is retained when summaries wrap, with a +consistent two-space hit-body indent. Result headlines combine count, type breakdown when completed, and pagination when known, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 8a3721ae..4a11e7d0 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -58,7 +58,9 @@ describe("renderUnifiedSearchStatusText", () => { expect(firstLine(text)).toBe( "Indexing continues - 1 interim result returned", ); - expect(text).toContain("[1] docs | Routing"); + expect(text).toContain( + "express/routing [docs page] npm:express - source URL unavailable - Routing", + ); expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 44a61701..33e03257 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -257,16 +257,16 @@ describe("renderUnifiedSearchSuccess", () => { "Sources: expressjs.com; expressjs/express@dbac741a", ); expect(text).toContain( - "[1] repo doc | npm:express@5.2.1 | History.md:169-179", + "[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01", ); expect(text).toContain( - "[6] docs | router.use()\n https://expressjs.com/en/api/router/0", + "[6] opaque-page-0 [docs page] npm:express - expressjs.com/en/api/router/0 - router.use()", ); expect(text).toContain(" * remove:"); expect(text).toContain(" - Remove Express 3.x middleware error stubs"); expect(text).not.toContain("githits docs read"); expect(text).not.toContain("docs_read"); - expect(text).not.toContain("opaque-page"); + expect(text).toContain("opaque-page-0 [docs page]"); expect(text).not.toContain("### router.use()"); expect(text.match(/next_offset=10/g)).toHaveLength(1); expect(text.length).toBeLessThan(3459); @@ -278,9 +278,11 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toContain("1 result"); expect(firstLine(text)).not.toContain("search |"); expect(text).toContain( - "[1] code | cline/cline@v3.4.2 | src/integrations/diff/strategies/multi-search-replace.ts:142-156", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - applyEdit", + ); + expect(text).toContain( + " Search/replace block parser with fuzzy fallback when exact match fails.", ); - expect(text).toContain(" applyEdit"); expect(text).not.toContain("searchRef="); }); @@ -317,24 +319,30 @@ describe("renderUnifiedSearchSuccess", () => { locator: { filePath: "History.md", startLine: 1 }, }, docsHit({ - title: "Документация", + title: "Документация | API - section", summary: "Café — маршрутизация", }), ]), ); - expect(text).toContain("[1] code | cline/cline@v3.4.2 |"); - expect(text).toContain("[2] repo doc | npm:express@5.2.1 | History.md:1"); - expect(text).toContain("[3] docs | aider-AI/aider@v0.55.0 | Документация"); - expect(text).toContain(" Überprüfung · human review"); + expect(text).toContain( + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - Überprüfung · human review", + ); + expect(text).toContain( + "[2] npm:express@5.2.1 History.md:1 [repo doc] - Résumé", + ); + expect(text).toContain( + "[3] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Документация | API - section", + ); expect(text).toContain("Café — маршрутизация"); expect(text).toContain("Résumé"); }); it("keeps documentation targets compact unless multiple targets need attribution", () => { const single = renderUnifiedSearchSuccess(completed([docsHit()])); - expect(single).toContain("[1] docs | Edit Formats"); - expect(single).not.toContain("[1] docs | aider-AI/aider@v0.55.0"); + expect(single).toContain( + "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", + ); const multiple = renderUnifiedSearchSuccess( completed([ @@ -342,23 +350,67 @@ describe("renderUnifiedSearchSuccess", () => { docsHit({ target: "npm:express@5.2.1", title: "Routing", - locator: { sourceUrl: "https://expressjs.com/en/guide/routing" }, + locator: { + pageId: "express/routing", + sourceUrl: "https://expressjs.com/en/guide/routing", + }, }), ]), ); expect(multiple).toContain( - "[1] docs | aider-AI/aider@v0.55.0 | Edit Formats", + "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", + ); + expect(multiple).toContain( + "[2] express/routing [docs page] npm:express - expressjs.com/en/guide/routing - Routing", ); - expect(multiple).toContain("[2] docs | npm:express@5.2.1 | Routing"); }); - it("states when a documentation source URL is unavailable without exposing its page ID", () => { + it("retains a documentation page ID when its source URL is unavailable", () => { const text = renderUnifiedSearchSuccess( completed([docsHit({ locator: { pageId: "internal-page-id" } })]), ); - expect(text).toContain("[1] docs | Edit Formats\n Source URL unavailable"); - expect(text).not.toContain("internal-page-id"); + expect(text).toContain( + "[1] internal-page-id [docs page] aider-AI/aider - source URL unavailable - Edit Formats", + ); + }); + + it("keeps docs follow-up locators before a free-form title tail", () => { + const text = renderUnifiedSearchSuccess( + completed([ + docsHit({ + target: "npm:express@5.2.1", + title: "router.route() | API - section", + locator: { + pageId: "386050", + registry: "npm", + packageName: "express", + version: "5.2.1", + sourceUrl: "https://expressjs.com/en/4x/api/router/#routerroute", + }, + }), + ]), + ); + + expect(text).toContain( + "[1] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route() | API - section", + ); + }); + + it("keeps repository symbol locators before a free-form title tail", () => { + const text = renderUnifiedSearchSuccess( + completed([ + codeHit({ + type: "repository_symbol", + title: "parse | request - options", + locator: { filePath: "src/index.ts", startLine: 10, endLine: 12 }, + }), + ]), + ); + + expect(text).toContain( + "[1] cline/cline@v3.4.2 src/index.ts:10-12 [repo symbol] - parse | request - options", + ); }); it("marks repository hits whose human location is unavailable", () => { @@ -367,7 +419,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "[1] code | cline/cline@v3.4.2 | location unavailable", + "[1] cline/cline@v3.4.2 location unavailable [repo code] - applyEdit", ); }); @@ -475,7 +527,7 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(code).toContain( - "[1] code | cline/cline@v3.4.2 | src/integrations/diff/strategies/multi-search-replace.ts:142-156", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - applyEdit", ); const repositoryCode = renderUnifiedSearchSuccess( @@ -494,14 +546,14 @@ describe("renderUnifiedSearchSuccess", () => { { actionSyntax: "cli" }, ); expect(repositoryCode).toContain( - "[1] code | github:cline/cline#main | src/index.ts:10-20", + "[1] github:cline/cline#main src/index.ts:10-20 [repo code] - applyEdit", ); const docs = renderUnifiedSearchSuccess(completed([docsHit()]), { actionSyntax: "cli", }); expect(docs).toContain( - "[1] docs | Edit Formats\n https://aider.chat/docs/more/edit-formats.html", + "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", ); const empty = renderUnifiedSearchSuccess( @@ -1350,9 +1402,11 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(text).toContain( - "[1] code | cline/cline@v3.4.2 | src/integrations/diff/strategies/multi-search-replace.ts:142-156", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - applyEdit", + ); + expect(text).toContain( + "[2] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", ); - expect(text).toContain("[2] docs | aider-AI/aider@v0.55.0 | Edit Formats"); expect(text).toContain( "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", ); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 9a7e07c5..c977fee8 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -791,13 +791,9 @@ function appendUnifiedSearchHits( hits: UnifiedSearchHitPayload[], options: NormalizedTextOptions, ): void { - const hitTargets = new Set( - hits.map((hit) => hit.requestedTarget ?? hit.target), - ); - const showDocsTarget = hitTargets.size > 1; hits.forEach((hit, idx) => { if (idx > 0) lines.push(""); - appendHit(lines, idx + 1, hit, showDocsTarget, options); + appendHit(lines, idx + 1, hit, options); }); } @@ -805,28 +801,12 @@ function appendHit( lines: string[], index: number, hit: UnifiedSearchHitPayload, - showDocsTarget: boolean, options: NormalizedTextOptions, ): void { lines.push( - `[${index}] ${highlight(formatHitHeader(hit, showDocsTarget), options.useColors)}`, + `[${index}] ${highlight(formatHitHeader(hit), options.useColors)}`, ); - if (hit.type === "documentation_page") { - lines.push( - isHttpUrl(hit.locator.sourceUrl) - ? ` ${hit.locator.sourceUrl}` - : " Source URL unavailable", - ); - } - - const titleIsInHeader = hit.type === "documentation_page"; - if (hit.title && !titleIsInHeader) { - lines.push( - ` ${highlightRanges(hit.title, hit.highlights?.title, options.useColors)}`, - ); - } - const summary = prepareSummary(hit.summary, hit.title); if (summary) { lines.push( @@ -918,25 +898,32 @@ function highlightWrappedSegment( return highlightRanges(value, localRanges, true); } -function formatHitHeader( - hit: UnifiedSearchHitPayload, - showDocsTarget: boolean, -): string { +function formatHitHeader(hit: UnifiedSearchHitPayload): string { const loc = hit.locator; - const type = shortType(hit.type); if (hit.type === "documentation_page") { - return [ - type, - showDocsTarget ? (hit.requestedTarget ?? hit.target) : undefined, - hit.title ?? stripVersionFromTarget(hit.target), - ] - .filter(Boolean) - .join(SEP); + return `${loc.pageId ?? "page ID unavailable"} [docs page] ${formatDocumentationTarget(hit)} - ${formatDocumentationSourceUrl(loc.sourceUrl)} - ${hit.title ?? "title unavailable"}`; } const location = loc.filePath ? `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}` : "location unavailable"; - return [type, hit.target, location].filter(Boolean).join(SEP); + const type = shortType(hit.type); + return `${hit.target} ${location} [${type}]${hit.title ? ` - ${hit.title}` : ""}`; +} + +function formatDocumentationTarget(hit: UnifiedSearchHitPayload): string { + const { registry, packageName } = hit.locator; + if (registry && packageName) { + return `${registry.toLowerCase()}:${packageName}`; + } + return ( + stripVersionFromTarget(hit.requestedTarget ?? hit.target) || + "target unavailable" + ); +} + +function formatDocumentationSourceUrl(value: string | undefined): string { + if (!value) return "source URL unavailable"; + return value.replace(/^https?:\/\//, ""); } function stripVersionFromTarget(value: string | undefined): string { @@ -948,11 +935,9 @@ function stripVersionFromTarget(value: string | undefined): string { function shortType(type: string): string { switch (type) { case "repository_code": - return "code"; + return "repo code"; case "repository_symbol": - return "symbol"; - case "documentation_page": - return "docs"; + return "repo symbol"; case "repository_doc": return "repo doc"; default: @@ -960,13 +945,6 @@ function shortType(type: string): string { } } -function isHttpUrl(value: string | undefined): value is string { - return ( - value?.startsWith("http://") === true || - value?.startsWith("https://") === true - ); -} - interface PreparedSummary { text: string; offset: number; diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index e846c632..74a3c659 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -313,7 +313,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] code | npm:express@5.2.1 | index.js\n" + + "1 result\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + " Ready: payload text\n" + " Waiting: payload text\n" + " Available but not searched: payload text\n" + @@ -339,7 +339,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result | 1 code\n\n[1] code | npm:express@5.2.1 | index.js\n" + + "1 result | 1 code\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + " First summary paragraph.\n\n" + " status: payload text\n" + " searchRef=payload text\n" + @@ -357,7 +357,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] code | npm:express@5.2.1 | index.js", + "1 result\n\n[1] npm:express@5.2.1 index.js [repo code]", ); } return smokeResponse(name, args); @@ -370,8 +370,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result\n\n[1] docs | README\n" + - " https://docs.example.com/readme", + "1 result\n\n[1] page-1 [docs page] npm:express - docs.example.com/readme - README | API - section", ); } return smokeResponse(name, args); @@ -384,8 +383,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result | 1 docs page\n\n[1] docs | README\n" + - " Source URL unavailable", + "1 result | 1 docs page\n\n[1] page-1 [docs page] npm:express - source URL unavailable - README", ); } return smokeResponse(name, args); @@ -396,16 +394,16 @@ describe("runMcpSmoke", () => { it.each([ [ - "1 result\n\n[1] code | npm:express@5.2.1\n" + + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n" + " This payload mentions code_read but has no locator", ], [ - "1 result\n\n[1] code | npm:express@5.2.1\n" + + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n" + ' code_read target="npm:express@5.2.1"', ], - ["1 result\n\n[1] docs | README\n" + " documentation prose only"], + ["1 result\n\n[1] page-1 [docs page] npm:express - README"], [ - "1 result\n\n[1] code | npm:express@5.2.1\n" + + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n" + " ordinary title\n" + ' code_read target="npm:express@5.2.1" path="index.js"', ], diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 27ae9466..07d27e06 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -310,23 +310,31 @@ function assertSearchDefaultText(text: string, context: string): void { } function hasHumanSearchHitLocator(lines: string[]): boolean { - return lines.some((line, index) => { - const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+\|\s+(.+)$/.exec( - line, - ); - if (!match) return false; - if (match[1] === "docs") { - return /^(?: {2}https?:\/\/\S+| {2}Source URL unavailable)$/.test( - lines[index + 1] ?? "", + return lines.some((line) => { + const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); + if (docsMatch) { + const docsDetails = docsMatch[2]; + if (!docsDetails) return false; + const firstDivider = docsDetails.indexOf(" - "); + const secondDivider = docsDetails.indexOf(" - ", firstDivider + 3); + return ( + firstDivider > 0 && + secondDivider > firstDivider + 3 && + docsDetails.slice(secondDivider + 3).trim().length > 0 ); } - const value = match[2]; - if (!value) return false; - const parts = value.split(" | "); - const first = parts[0] ?? ""; - const last = parts[parts.length - 1] ?? ""; + const match = + /^\[\d+\]\s+(.+?)\s+\[(repo doc|repo code|repo symbol)\](?: - (.*))?$/.exec( + line, + ); + if (!match) return false; + const locatorText = match[1]; + if (!locatorText) return false; + const locator = locatorText.trim().split(/\s+/); return ( - parts.length >= 2 && first.trim().length > 0 && last.trim().length > 0 + locator.length >= 2 && + !locatorText.trim().endsWith("location unavailable") && + locator.every((part) => part.length > 0) ); }); } diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index cfdce260..9b9cfe94 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -490,23 +490,31 @@ export function assertSearchTerminalText(text: string, context: string): void { } function hasHumanSearchHitLocator(lines: string[]): boolean { - return lines.some((line, index) => { - const match = /^\[\d+\]\s+(repo doc|code|symbol|docs)\s+\|\s+(.+)$/.exec( - line, - ); - if (!match) return false; - if (match[1] === "docs") { - return /^(?: {2}https?:\/\/\S+| {2}Source URL unavailable)$/.test( - lines[index + 1] ?? "", + return lines.some((line) => { + const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); + if (docsMatch) { + const docsDetails = docsMatch[2]; + if (!docsDetails) return false; + const firstDivider = docsDetails.indexOf(" - "); + const secondDivider = docsDetails.indexOf(" - ", firstDivider + 3); + return ( + firstDivider > 0 && + secondDivider > firstDivider + 3 && + docsDetails.slice(secondDivider + 3).trim().length > 0 ); } - const value = match[2]; - if (!value) return false; - const parts = value.split(" | "); - const first = parts[0] ?? ""; - const last = parts[parts.length - 1] ?? ""; + const match = + /^\[\d+\]\s+(.+?)\s+\[(repo doc|repo code|repo symbol)\](?: - (.*))?$/.exec( + line, + ); + if (!match) return false; + const locatorText = match[1]; + if (!locatorText) return false; + const locator = locatorText.trim().split(/\s+/); return ( - parts.length >= 2 && first.trim().length > 0 && last.trim().length > 0 + locator.length >= 2 && + !locatorText.trim().endsWith("location unavailable") && + locator.every((part) => part.length > 0) ); }); } diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 3ee5f8f3..f5654f83 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -40,11 +40,10 @@ Next: githits search-status smoke-ref --wait 20`; Next: shorten or broaden query; use githits code grep.`; const completed = `1 result | 1 code | next_offset=10 -[1] code | npm:express@5.2.1 | lib/application.js`; +[1] npm:express@5.2.1 lib/application.js [repo code]`; const completedDocs = `1 result | 1 docs page -[1] docs | Getting started - https://docs.example.com/getting-started`; +[1] page-1 [docs page] npm:express - docs.example.com/getting-started - Getting started | API - section`; it("accepts outcome-first text with CLI-native actions", () => { expect(valid.split("\n")[0]).toBe("Indexing - no results yet"); @@ -73,7 +72,7 @@ Next: shorten or broaden query; use githits code grep.`; [`Warning: indexing\n${valid}`, "non-outcome text"], [`${completed}\nstatus: indexing`, "lifecycle status"], [ - completed.replace(" | lib/application.js", ""), + completed.replace(" lib/application.js [repo code]", ""), "missing result follow-up", ], ["1 result from npm:express@5.2.1", "missing result follow-up"], @@ -94,7 +93,7 @@ Next: shorten or broaden query; use githits code grep.`; it("accepts documentation hits that disclose a missing source URL", () => { expect(() => assertSearchTerminalText( - "1 result | 1 docs page\n\n[1] docs | README\n Source URL unavailable", + "1 result | 1 docs page\n\n[1] page-1 [docs page] npm:express - source URL unavailable - README", "search", ), ).not.toThrow(); @@ -102,15 +101,18 @@ Next: shorten or broaden query; use githits code grep.`; it.each([ [ - "1 result\n\n[1] code | npm:express@5.2.1\n This payload mentions githits code read but has no locator", + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n This payload mentions githits code read but has no locator", ], [ - "1 result\n\n[1] code | npm:express@5.2.1\n githits code read 'npm:express@5.2.1' --lines 1-10", + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n githits code read 'npm:express@5.2.1' --lines 1-10", ], [ - "1 result\n\n[1] code | npm:express@5.2.1\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n ordinary title\n githits code read 'npm:express@5.2.1' 'index.js'", + ], + [ + "1 result\n\n[1] page-1 [docs page] npm:express - README\n" + + " githits docs read --lines 1-10", ], - ["1 result\n\n[1] docs | README\n githits docs read --lines 1-10"], ])("rejects incomplete or prose-only hit follow-ups", (text) => { expect(() => assertSearchTerminalText(text, "search")).toThrow( "missing result follow-up or next action", @@ -218,7 +220,7 @@ Next: shorten or broaden query; use githits code grep.`; it("ignores formatter-like words and diagnostics in indented hit content", () => { const hitText = `1 result -[1] code | npm:express@5.2.1 | lib/application.js +[1] npm:express@5.2.1 lib/application.js [repo code] Ready: payload text Waiting: payload text Available but not searched: payload text @@ -238,7 +240,7 @@ Next: shorten or broaden query; use githits code grep.`; it("keeps multiline hit-body diagnostics opaque after a blank line", () => { const hitText = - "1 result | 1 code\n\n[1] code | npm:express@5.2.1 | index.js\n" + + "1 result | 1 code\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + " First summary paragraph.\n\n" + " status: payload text\n" + " searchRef=payload text\n" + From 83cde61b5828573aff23c9fbb86f675957fa2684 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 11:15:07 +0300 Subject: [PATCH 42/46] test: align CLI search output expectations Update direct CLI consumer coverage for the shared locator-first search grammar, including documentation page IDs, scheme-free source URLs, ranked repo-code headers, and ANSI output. --- src/commands/search.test.ts | 40 ++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 73d207cb..b91b1abf 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -713,8 +713,10 @@ describe("searchAction", () => { expect(output).toContain( "Searched: repository docs, expressjs.com/en/guide docs", ); - expect(output).toContain("[1] docs | Routing"); - expect(output).toContain("https://expressjs.com/en/guide/routing.html"); + expect(output).toContain( + "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html - Routing", + ); + expect(output).toContain("expressjs.com/en/guide/routing.html"); expect(output).not.toContain("Documentation sources"); expect(output).not.toContain("hits on this page"); expect(output).not.toContain("124 pages"); @@ -938,7 +940,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("1 result | 1 code"); expect(output).toContain( - "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", + "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); expect(output).not.toContain("githits code read"); expect(output).toContain("router middleware"); @@ -1158,7 +1160,7 @@ describe("searchAction", () => { expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( - "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", + "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); expect(output).toContain("Search ref-deferred | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); @@ -1197,7 +1199,7 @@ describe("searchAction", () => { ); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( - "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", + "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); @@ -1649,12 +1651,14 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("\u001b[1m\u001b[33mmiddleware\u001b[0m"); + expect(output).toContain( + "\u001b[1m\u001b[36mnpm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware\u001b[0m", + ); expect(output).toContain( "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mcode | npm:express@4.18.2 | lib/router/index.js:42-57\u001b[0m", + "\u001b[1m\u001b[36mnpm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware\u001b[0m", ); } finally { consoleSpy.mockRestore(); @@ -1775,7 +1779,7 @@ describe("searchAction", () => { } }); - it("shows direct source URLs and hides page IDs for documentation pages", async () => { + it("shows direct source URLs and retains page IDs for documentation pages", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); if (defaultUnifiedSearchOutcome.state !== "completed") { @@ -1816,9 +1820,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("[1] docs | Using Express middleware"); - expect(output).toContain("https://hexdocs.pm/express/getting-started.html"); - expect(output).not.toContain("docs-123"); + expect(output).toContain( + "[1] docs-123 [docs page] npm:express - hexdocs.pm/express/getting-started.html - Using Express middleware", + ); + expect(output).toContain("hexdocs.pm/express/getting-started.html"); + expect(output).toContain("docs-123"); expect(output).toContain("Using Express middleware"); expect(output).not.toContain("source:"); expect(output).not.toContain("npm:express@4.18.2 [docs page]"); @@ -1865,8 +1871,10 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("[1] docs | Routing"); - expect(output).toContain("https://docs.example/routing"); + expect(output).toContain( + "[1] docs-routing [docs page] docs.example - docs.example/routing - Routing", + ); + expect(output).toContain("docs.example/routing"); expect(output).toContain("Routing"); consoleSpy.mockRestore(); }); @@ -2262,7 +2270,7 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); expect(output).toContain( - "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", + "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); expect(output).toContain("Search ref-deferred | 1/2 targets ready"); expect(output).toContain("Next: rerun search later."); @@ -2301,7 +2309,7 @@ describe("searchStatusAction", () => { "FUTURE_SESSION_STATE - 1 result returned", ); expect(output).toContain( - "[1] code | npm:express@4.18.2 | lib/router/index.js:42-57", + "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); expect(output).toContain("Search ref-future | 0/1 target ready"); expect(output).toContain("Next: rerun search later."); @@ -2594,7 +2602,7 @@ describe("searchStatusAction", () => { "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mcode | npm:express@4.18.2 | lib/router/index.js:42-57\u001b[0m", + "\u001b[1m\u001b[36mnpm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware\u001b[0m", ); } finally { consoleSpy.mockRestore(); From 255623283eb455fd269970e5885351fa64dd21d9 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 11:18:14 +0300 Subject: [PATCH 43/46] docs: document unified search separators Clarify that formatter-authored search punctuation is ASCII across both pipe and hyphen separators while backend payload Unicode remains unchanged. --- docs/implementation/tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 54fc3527..58fa041b 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -297,7 +297,7 @@ The `hint` field is emitted only when the cap *actually truncated* the response **In-place evolution.** `text-v1` names the compact line-oriented representation; it is not an exact-prose compatibility boundary. Search and `search_status` may tighten human/agent copy in place as long as their structural lifecycle, ordering, action, and hit-anatomy invariants remain covered by tests (`packages/mcp/src/shared/unified-search-text.test.ts`, `packages/mcp/src/tools/search-status.test.ts`). JSON is the stable structured boundary for programmatic callers. Other text-v1 renderers retain their own contracts and are not changed by the search presentation work. -**Compact punctuation.** Formatter-authored separators are ASCII ` | `; ellipsis is `...`; no box-drawing or decorative punctuation. Unicode in backend payloads (titles, summaries, paths, URLs, and notes) passes through unchanged. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — the small fixed vocabulary keeps it predictable. +**Compact punctuation.** Formatter-authored punctuation is ASCII, including the ` | ` and ` - ` separators; ellipsis is `...`; no box-drawing or decorative punctuation. Unicode in backend payloads (titles, summaries, paths, URLs, and notes) passes through unchanged. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — the small fixed vocabulary keeps it predictable. **Example-search anatomy.** `get_example` text mode returns markdown directly, followed by `solution_id: <id>` when the REST response includes an app URL. This avoids JSON-wrapped markdown while preserving the `feedback` workflow. `search_language` text mode returns one match per line as `name (Display Name) aliases: a, b`; agents should pass the `name` value to `get_example.language`. From 2cce8f519e158509fce94a531b54a83ca7369f09 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 11:37:52 +0300 Subject: [PATCH 44/46] fix: preserve search hit hierarchy Keep fixed hit locators intact while wrapping only long title tails, and restore semantic ANSI emphasis for locators and title matches. Align result breakdown labels and document unavailable repository locations so CLI and MCP output remain consistent. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/tools.md | 11 +- .../src/shared/unified-search-text.test.ts | 158 ++++++++++++++++-- .../mcp/src/shared/unified-search-text.ts | 95 +++++++++-- packages/mcp/src/smoke-test.test.ts | 2 +- scripts/smoke-scripts.test.ts | 4 +- src/commands/search.test.ts | 12 +- 7 files changed, 244 insertions(+), 40 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 54557b21..218f99b1 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -236,7 +236,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search <ref> | <ready>/<total> target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Breakdown labels use `repo code hit(s)`, `repo symbol(s)`, `repo doc(s)`, and `docs page(s)`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search <ref> | <ready>/<total> target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 58fa041b..1177f85c 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -372,14 +372,19 @@ or `title unavailable` values. Executable `docs_read` / `code_read` command lines, qualified non-follow-up internal result IDs, and kind/category tails are omitted from default text; the documentation page ID remains because it is the `docs_read` follow-up locator, and JSON keeps the full locator and follow-up -fields unchanged. A summary's first line is omitted when it repeats the title +fields unchanged. Repository hits without a file path use the explicit +`location unavailable` value and do not claim to be follow-up readable. A +summary's first line is omitted when it repeats the title after removing Markdown heading markers, as is an immediately following setext underline. Source indentation is retained when summaries wrap, with a -consistent two-space hit-body indent. +consistent two-space hit-body indent. If a title does not fit on the header +line, the fixed locator prefix stays unwrapped with a trailing ` -`, and only +the title continues on two-space-indented lines. Result headlines combine count, type breakdown when completed, and pagination when known, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. -When more results exist without a next offset, the final field is +Breakdowns use `repo code hit(s)` and `repo symbol(s)` alongside `repo doc(s)` +and `docs page(s)`. When more results exist without a next offset, the final field is `more available`. Pagination is not repeated as a bottom paragraph. **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 33e03257..ae3fc47d 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -171,6 +171,11 @@ function firstLine(text: string): string { return text.split("\n")[0] ?? ""; } +const ANSI_SGR_PATTERN = new RegExp( + `${String.fromCharCode(0x1b)}\\[[0-9;]*m`, + "g", +); + describe("renderUnifiedSearchSuccess", () => { it("renders completed Express results as compact ranked source-backed hits", () => { const repoSummary = @@ -260,7 +265,7 @@ describe("renderUnifiedSearchSuccess", () => { "[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01", ); expect(text).toContain( - "[6] opaque-page-0 [docs page] npm:express - expressjs.com/en/api/router/0 - router.use()", + "[6] opaque-page-0 [docs page] npm:express - expressjs.com/en/api/router/0 -\n router.use()", ); expect(text).toContain(" * remove:"); expect(text).toContain(" - Remove Express 3.x middleware error stubs"); @@ -278,7 +283,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(text)).toContain("1 result"); expect(firstLine(text)).not.toContain("search |"); expect(text).toContain( - "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - applyEdit", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n applyEdit", ); expect(text).toContain( " Search/replace block parser with fuzzy fallback when exact match fails.", @@ -302,6 +307,16 @@ describe("renderUnifiedSearchSuccess", () => { expect(firstLine(repoText)).toBe("1 result | 1 repo doc"); expect(firstLine(docsText)).toBe("1 result | 1 docs page"); + expect(firstLine(renderUnifiedSearchSuccess(completed([codeHit()])))).toBe( + "1 result | 1 repo code hit", + ); + expect( + firstLine( + renderUnifiedSearchSuccess( + completed([codeHit({ type: "repository_symbol" })]), + ), + ), + ).toBe("1 result | 1 repo symbol"); }); it("uses ASCII separators without changing Unicode payload text", () => { @@ -326,13 +341,13 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - Überprüfung · human review", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n Überprüfung · human review", ); expect(text).toContain( "[2] npm:express@5.2.1 History.md:1 [repo doc] - Résumé", ); expect(text).toContain( - "[3] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Документация | API - section", + "[3] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Документация | API - section", ); expect(text).toContain("Café — маршрутизация"); expect(text).toContain("Résumé"); @@ -341,7 +356,7 @@ describe("renderUnifiedSearchSuccess", () => { it("keeps documentation targets compact unless multiple targets need attribution", () => { const single = renderUnifiedSearchSuccess(completed([docsHit()])); expect(single).toContain( - "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", + "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Edit Formats", ); const multiple = renderUnifiedSearchSuccess( @@ -358,10 +373,10 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); expect(multiple).toContain( - "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", + "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Edit Formats", ); expect(multiple).toContain( - "[2] express/routing [docs page] npm:express - expressjs.com/en/guide/routing - Routing", + "[2] express/routing [docs page] npm:express - expressjs.com/en/guide/routing -\n Routing", ); }); @@ -371,7 +386,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "[1] internal-page-id [docs page] aider-AI/aider - source URL unavailable - Edit Formats", + "[1] internal-page-id [docs page] aider-AI/aider - source URL unavailable -\n Edit Formats", ); }); @@ -393,7 +408,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "[1] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route() | API - section", + "[1] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute -\n router.route() | API - section", ); }); @@ -409,7 +424,116 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "[1] cline/cline@v3.4.2 src/index.ts:10-12 [repo symbol] - parse | request - options", + "[1] cline/cline@v3.4.2 src/index.ts:10-12 [repo symbol] -\n parse | request - options", + ); + }); + + it("wraps long title tails without wrapping fixed locator prefixes", () => { + const repoTitle = + "Repository title that is deliberately long enough to wrap at terminal boundaries"; + const docsTitle = + "Documentation title | API - deliberately long enough to wrap at terminal boundaries"; + const payload = completed([ + codeHit({ title: repoTitle, summary: undefined }), + docsHit({ title: docsTitle, summary: undefined }), + ]); + const repoPrefix = + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -"; + const docsPrefix = + "[2] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -"; + + for (const width of [40, 80]) { + const text = renderUnifiedSearchSuccess(payload, { + width, + useColors: false, + }); + const lines = text.split("\n"); + const repoPrefixIndex = lines.indexOf(repoPrefix); + const docsPrefixIndex = lines.indexOf(docsPrefix); + expect(repoPrefixIndex).toBeGreaterThanOrEqual(0); + expect(docsPrefixIndex).toBeGreaterThanOrEqual(0); + expect(lines[repoPrefixIndex + 1]).toMatch(/^ {2}Repository title that/); + expect(lines[docsPrefixIndex + 1]).toMatch( + /^ {2}Documentation title \| API -/, + ); + expect(lines[repoPrefixIndex]).not.toContain(repoTitle); + expect(lines[docsPrefixIndex]).not.toContain(docsTitle); + const normalizedTitle = (start: number, end: number) => + lines + .slice(start + 1, end) + .filter((line) => line.startsWith(" ")) + .map((line) => line.slice(2)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + expect(normalizedTitle(repoPrefixIndex, docsPrefixIndex)).toBe(repoTitle); + expect(normalizedTitle(docsPrefixIndex, lines.length)).toBe(docsTitle); + expect( + lines + .slice(repoPrefixIndex + 1) + .filter((line) => line.startsWith(" ")) + .every((line) => line.length <= width), + ).toBe(true); + } + + expect(renderUnifiedSearchSuccess(payload)).toBe( + renderUnifiedSearchSuccess(payload, { width: 80, useColors: false }), + ); + + const multiline = renderUnifiedSearchSuccess( + completed([ + codeHit({ + title: "First title line\nSecond title line", + summary: undefined, + }), + ]), + { width: 200, useColors: false }, + ); + expect(multiline).toContain( + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n" + + " First title line\n Second title line", + ); + }); + + it("keeps semantic ANSI styling and title match highlights in headers", () => { + const text = renderUnifiedSearchSuccess( + completed([ + codeHit({ + title: "applyEdit", + highlights: { title: [[0, 5]] }, + }), + docsHit({ + title: "Edit Formats", + highlights: { title: [[0, 4]] }, + }), + ]), + { useColors: true, width: 200 }, + ); + const reset = "\u001b[0m"; + const locator = "\u001b[1m\u001b[36m"; + const matched = "\u001b[1m\u001b[33m"; + const secondary = "\u001b[2m"; + + expect(text).toContain( + `[1] ${locator}cline/cline@v3.4.2${reset} ${locator}src/integrations/diff/strategies/multi-search-replace.ts:142-156${reset} ${secondary}[repo code]${reset} - ${matched}apply${reset}Edit`, + ); + expect(text).toContain( + `[2] ${locator}aider/edit-formats${reset} ${secondary}[docs page]${reset} ${secondary}aider-AI/aider${reset} - ${secondary}aider.chat/docs/more/edit-formats.html${reset} - ${matched}Edit${reset} Formats`, + ); + expect(text.replace(ANSI_SGR_PATTERN, "")).toBe( + renderUnifiedSearchSuccess( + completed([ + codeHit({ + title: "applyEdit", + highlights: { title: [[0, 5]] }, + }), + docsHit({ + title: "Edit Formats", + highlights: { title: [[0, 4]] }, + }), + ]), + { useColors: false, width: 200 }, + ), ); }); @@ -527,7 +651,7 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(code).toContain( - "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - applyEdit", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n applyEdit", ); const repositoryCode = renderUnifiedSearchSuccess( @@ -553,7 +677,7 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(docs).toContain( - "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", + "[1] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Edit Formats", ); const empty = renderUnifiedSearchSuccess( @@ -968,7 +1092,7 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); - expect(firstLine(text)).toBe("2 results | 2 code"); + expect(firstLine(text)).toBe("2 results | 2 repo code hits"); expect(firstLine(text)).not.toContain(" from "); }); @@ -1113,7 +1237,7 @@ describe("renderUnifiedSearchSuccess", () => { ), ); - expect(firstLine(text)).toBe("1 result | 1 code"); + expect(firstLine(text)).toBe("1 result | 1 repo code hit"); expect(text).toContain("- npm:express latest -> 5.2.1"); expect(text.match(/Using:/g)).toHaveLength(1); expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); @@ -1132,7 +1256,7 @@ describe("renderUnifiedSearchSuccess", () => { ]), ); - expect(firstLine(text)).toBe("1 result | 1 code"); + expect(firstLine(text)).toBe("1 result | 1 repo code hit"); expect(text).toContain("- npm:express latest -> 5.2.1"); expect(text.match(/Using:/g)).toHaveLength(1); expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); @@ -1402,10 +1526,10 @@ describe("renderUnifiedSearchSuccess", () => { actionSyntax: "cli", }); expect(text).toContain( - "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] - applyEdit", + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n applyEdit", ); expect(text).toContain( - "[2] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html - Edit Formats", + "[2] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Edit Formats", ); expect(text).toContain( "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index c977fee8..f5d0c96f 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -214,7 +214,8 @@ function resultCountLabel(label: string, count: number): string { if (count !== 1) return label; if (label === "repo docs") return "repo doc"; if (label === "docs pages") return "docs page"; - if (label === "symbols") return "symbol"; + if (label === "repo code hits") return "repo code hit"; + if (label === "repo symbols") return "repo symbol"; return label; } @@ -225,9 +226,9 @@ function resultBreakdownLabel(type: string): string { case "documentation_page": return "docs pages"; case "repository_symbol": - return "symbols"; + return "repo symbols"; case "repository_code": - return "code"; + return "repo code hits"; default: return type; } @@ -803,9 +804,29 @@ function appendHit( hit: UnifiedSearchHitPayload, options: NormalizedTextOptions, ): void { - lines.push( - `[${index}] ${highlight(formatHitHeader(hit), options.useColors)}`, - ); + const header = formatHitHeader(hit); + const rank = `[${index}] `; + const prefix = renderHitHeaderPrefix(header, options.useColors); + const title = header.title; + const titleFits = + title === undefined || + (!title.includes("\n") && + rank.length + header.prefix.length + 3 + title.length <= options.width); + if (titleFits) { + lines.push( + `${rank}${prefix}${title === undefined ? "" : ` - ${highlightRanges(title, header.titleHighlights, options.useColors)}`}`, + ); + } else { + lines.push(`${rank}${prefix} -`); + lines.push( + ...wrapHighlightedText( + title, + header.titleHighlights, + Math.max(1, options.width - 2), + options.useColors, + ).map((line) => (line.length === 0 ? "" : ` ${line}`)), + ); + } const summary = prepareSummary(hit.summary, hit.title); if (summary) { @@ -898,16 +919,70 @@ function highlightWrappedSegment( return highlightRanges(value, localRanges, true); } -function formatHitHeader(hit: UnifiedSearchHitPayload): string { +interface HitHeaderSegment { + text: string; + style: "plain" | "locator" | "secondary"; +} + +interface FormattedHitHeader { + prefix: string; + segments: HitHeaderSegment[]; + title?: string; + titleHighlights?: ReadonlyArray<readonly [number, number]>; +} + +function formatHitHeader(hit: UnifiedSearchHitPayload): FormattedHitHeader { const loc = hit.locator; if (hit.type === "documentation_page") { - return `${loc.pageId ?? "page ID unavailable"} [docs page] ${formatDocumentationTarget(hit)} - ${formatDocumentationSourceUrl(loc.sourceUrl)} - ${hit.title ?? "title unavailable"}`; + const pageId = loc.pageId ?? "page ID unavailable"; + const type = "[docs page]"; + const target = formatDocumentationTarget(hit); + const sourceUrl = formatDocumentationSourceUrl(loc.sourceUrl); + return { + prefix: `${pageId} ${type} ${target} - ${sourceUrl}`, + segments: [ + { text: pageId, style: "locator" }, + { text: " ", style: "plain" }, + { text: type, style: "secondary" }, + { text: " ", style: "plain" }, + { text: target, style: "secondary" }, + { text: " - ", style: "plain" }, + { text: sourceUrl, style: "secondary" }, + ], + title: hit.title || "title unavailable", + titleHighlights: hit.highlights?.title, + }; } const location = loc.filePath ? `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}` : "location unavailable"; - const type = shortType(hit.type); - return `${hit.target} ${location} [${type}]${hit.title ? ` - ${hit.title}` : ""}`; + const type = `[${shortType(hit.type)}]`; + return { + prefix: `${hit.target} ${location} ${type}`, + segments: [ + { text: hit.target, style: "locator" }, + { text: " ", style: "plain" }, + { text: location, style: "locator" }, + { text: " ", style: "plain" }, + { text: type, style: "secondary" }, + ], + title: hit.title || undefined, + titleHighlights: hit.highlights?.title, + }; +} + +function renderHitHeaderPrefix( + header: FormattedHitHeader, + useColors: boolean, +): string { + return header.segments + .map((segment) => { + if (!useColors || segment.style === "plain") return segment.text; + return segment.style === "locator" + ? highlight(segment.text, true) + : dim(segment.text, true); + }) + .join(""); } function formatDocumentationTarget(hit: UnifiedSearchHitPayload): string { diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 74a3c659..5b076d96 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -339,7 +339,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - "1 result | 1 code\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + + "1 result | 1 repo code hit\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + " First summary paragraph.\n\n" + " status: payload text\n" + " searchRef=payload text\n" + diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index f5654f83..51baefbb 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -38,7 +38,7 @@ Next: githits search-status smoke-ref --wait 20`; Searched: repository docs Next: shorten or broaden query; use githits code grep.`; - const completed = `1 result | 1 code | next_offset=10 + const completed = `1 result | 1 repo code hit | next_offset=10 [1] npm:express@5.2.1 lib/application.js [repo code]`; const completedDocs = `1 result | 1 docs page @@ -240,7 +240,7 @@ Next: shorten or broaden query; use githits code grep.`; it("keeps multiline hit-body diagnostics opaque after a blank line", () => { const hitText = - "1 result | 1 code\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + + "1 result | 1 repo code hit\n\n[1] npm:express@5.2.1 index.js [repo code]\n" + " First summary paragraph.\n\n" + " status: payload text\n" + " searchRef=payload text\n" + diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index b91b1abf..025624aa 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -714,7 +714,7 @@ describe("searchAction", () => { "Searched: repository docs, expressjs.com/en/guide docs", ); expect(output).toContain( - "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html - Routing", + "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html -\n Routing", ); expect(output).toContain("expressjs.com/en/guide/routing.html"); expect(output).not.toContain("Documentation sources"); @@ -938,7 +938,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("1 result | 1 code"); + expect(output.split("\n")[0]).toBe("1 result | 1 repo code hit"); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); @@ -1652,13 +1652,13 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "\u001b[1m\u001b[36mnpm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware\u001b[0m", + "[1] \u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m \u001b[1m\u001b[36mlib/router/index.js:42-57\u001b[0m \u001b[2m[repo code]\u001b[0m - router \u001b[1m\u001b[33mmiddleware\u001b[0m", ); expect(output).toContain( "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mnpm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware\u001b[0m", + "[1] \u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m \u001b[1m\u001b[36mlib/router/index.js:42-57\u001b[0m \u001b[2m[repo code]\u001b[0m - router \u001b[1m\u001b[33mmiddleware\u001b[0m", ); } finally { consoleSpy.mockRestore(); @@ -1821,7 +1821,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "[1] docs-123 [docs page] npm:express - hexdocs.pm/express/getting-started.html - Using Express middleware", + "[1] docs-123 [docs page] npm:express - hexdocs.pm/express/getting-started.html -\n Using Express middleware", ); expect(output).toContain("hexdocs.pm/express/getting-started.html"); expect(output).toContain("docs-123"); @@ -2602,7 +2602,7 @@ describe("searchStatusAction", () => { "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); expect(output).toContain( - "\u001b[1m\u001b[36mnpm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware\u001b[0m", + "[1] \u001b[1m\u001b[36mnpm:express@4.18.2\u001b[0m \u001b[1m\u001b[36mlib/router/index.js:42-57\u001b[0m \u001b[2m[repo code]\u001b[0m - router \u001b[1m\u001b[33mmiddleware\u001b[0m", ); } finally { consoleSpy.mockRestore(); From ab46bea34b2da3c803b76b61b8eee831b8101108 Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 12:02:27 +0300 Subject: [PATCH 45/46] fix: validate wrapped search hit headers Accept the shared formatter's wrapped title tails in both CLI and MCP smoke validators while keeping locator metadata and unavailable-field checks strict. --- packages/mcp/src/smoke-test.test.ts | 30 ++++++++++++++++++++ packages/mcp/src/smoke-test.ts | 44 +++++++++++++++++++++-------- scripts/cli-smoke.ts | 44 +++++++++++++++++++++-------- scripts/smoke-scripts.test.ts | 26 +++++++++++++++++ 4 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 5b076d96..4aa5b33a 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -392,6 +392,23 @@ describe("runMcpSmoke", () => { await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); }); + it("allows wrapped documentation and repository title tails", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "2 results | 1 repo code hit, 1 docs page\n\n" + + "[1] page-1 [docs page] npm:express - docs.example.com/readme -\n" + + " A long documentation title\n\n" + + "[2] npm:express@5.2.1 lib/application.js [repo code] -\n" + + " A long repository title", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + it.each([ [ "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n" + @@ -402,11 +419,24 @@ describe("runMcpSmoke", () => { ' code_read target="npm:express@5.2.1"', ], ["1 result\n\n[1] page-1 [docs page] npm:express - README"], + [ + "1 result\n\n[1] page-1 [docs page] npm:express -\n" + + " README without a source locator", + ], + [ + "1 result\n\n[1] page ID unavailable [docs page] npm:express - docs.example.com/readme -\n" + + " Wrapped title without a page locator", + ], [ "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n" + " ordinary title\n" + ' code_read target="npm:express@5.2.1" path="index.js"', ], + [ + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code] -\n" + + " Wrapped title without a locator", + ], + ["1 result\n\n[1] npm:express@5.2.1 lib/application.js [repo code] -"], ])("rejects incomplete or prose-only hit follow-ups", async (searchText) => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 07d27e06..d2b3123c 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -310,35 +310,57 @@ function assertSearchDefaultText(text: string, context: string): void { } function hasHumanSearchHitLocator(lines: string[]): boolean { - return lines.some((line) => { + return lines.some((line, index) => { const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); if (docsMatch) { + const pageId = docsMatch[1]; const docsDetails = docsMatch[2]; - if (!docsDetails) return false; + if (!pageId || pageId === "page ID unavailable" || !docsDetails) { + return false; + } const firstDivider = docsDetails.indexOf(" - "); - const secondDivider = docsDetails.indexOf(" - ", firstDivider + 3); - return ( - firstDivider > 0 && - secondDivider > firstDivider + 3 && - docsDetails.slice(secondDivider + 3).trim().length > 0 - ); + if (firstDivider <= 0) return false; + const sourceAndTitle = docsDetails.slice(firstDivider + 3); + const secondDivider = sourceAndTitle.indexOf(" - "); + if (secondDivider > 0) { + const source = sourceAndTitle.slice(0, secondDivider).trim(); + const title = sourceAndTitle.slice(secondDivider + 3).trim(); + return ( + source.length > 0 && + (title.length > 0 || hasWrappedHitTitle(lines, index)) + ); + } + if (!sourceAndTitle.endsWith(" -")) return false; + const source = sourceAndTitle.slice(0, -2).trim(); + return source.length > 0 && hasWrappedHitTitle(lines, index); } const match = - /^\[\d+\]\s+(.+?)\s+\[(repo doc|repo code|repo symbol)\](?: - (.*))?$/.exec( + /^\[\d+\]\s+(.+?)\s+\[(repo doc|repo code|repo symbol)\](?: -(?: (.*))?)?$/.exec( line, ); if (!match) return false; const locatorText = match[1]; if (!locatorText) return false; const locator = locatorText.trim().split(/\s+/); - return ( + if ( locator.length >= 2 && !locatorText.trim().endsWith("location unavailable") && locator.every((part) => part.length > 0) - ); + ) { + const title = match[3]; + return title === undefined + ? !line.endsWith(" -") + : title.trim().length > 0 || hasWrappedHitTitle(lines, index); + } + return false; }); } +function hasWrappedHitTitle(lines: string[], index: number): boolean { + const titleLine = lines[index + 1]; + return titleLine?.startsWith(" ") === true && titleLine.trim().length > 0; +} + function searchFormatterLines(lines: string[]): string[] { let inHit = false; return lines.filter((line) => { diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 9b9cfe94..79b6400a 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -490,35 +490,57 @@ export function assertSearchTerminalText(text: string, context: string): void { } function hasHumanSearchHitLocator(lines: string[]): boolean { - return lines.some((line) => { + return lines.some((line, index) => { const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); if (docsMatch) { + const pageId = docsMatch[1]; const docsDetails = docsMatch[2]; - if (!docsDetails) return false; + if (!pageId || pageId === "page ID unavailable" || !docsDetails) { + return false; + } const firstDivider = docsDetails.indexOf(" - "); - const secondDivider = docsDetails.indexOf(" - ", firstDivider + 3); - return ( - firstDivider > 0 && - secondDivider > firstDivider + 3 && - docsDetails.slice(secondDivider + 3).trim().length > 0 - ); + if (firstDivider <= 0) return false; + const sourceAndTitle = docsDetails.slice(firstDivider + 3); + const secondDivider = sourceAndTitle.indexOf(" - "); + if (secondDivider > 0) { + const source = sourceAndTitle.slice(0, secondDivider).trim(); + const title = sourceAndTitle.slice(secondDivider + 3).trim(); + return ( + source.length > 0 && + (title.length > 0 || hasWrappedHitTitle(lines, index)) + ); + } + if (!sourceAndTitle.endsWith(" -")) return false; + const source = sourceAndTitle.slice(0, -2).trim(); + return source.length > 0 && hasWrappedHitTitle(lines, index); } const match = - /^\[\d+\]\s+(.+?)\s+\[(repo doc|repo code|repo symbol)\](?: - (.*))?$/.exec( + /^\[\d+\]\s+(.+?)\s+\[(repo doc|repo code|repo symbol)\](?: -(?: (.*))?)?$/.exec( line, ); if (!match) return false; const locatorText = match[1]; if (!locatorText) return false; const locator = locatorText.trim().split(/\s+/); - return ( + if ( locator.length >= 2 && !locatorText.trim().endsWith("location unavailable") && locator.every((part) => part.length > 0) - ); + ) { + const title = match[3]; + return title === undefined + ? !line.endsWith(" -") + : title.trim().length > 0 || hasWrappedHitTitle(lines, index); + } + return false; }); } +function hasWrappedHitTitle(lines: string[], index: number): boolean { + const titleLine = lines[index + 1]; + return titleLine?.startsWith(" ") === true && titleLine.trim().length > 0; +} + function searchFormatterLines(lines: string[]): string[] { let inHit = false; return lines.filter((line) => { diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 51baefbb..ebc346dd 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -99,6 +99,19 @@ Next: shorten or broaden query; use githits code grep.`; ).not.toThrow(); }); + it("accepts wrapped documentation and repository title tails", () => { + expect(() => + assertSearchTerminalText( + "2 results | 1 repo code hit, 1 docs page\n\n" + + "[1] page-1 [docs page] npm:express - docs.example.com/getting-started -\n" + + " A long documentation title\n\n" + + "[2] npm:express@5.2.1 lib/application.js [repo code] -\n" + + " A long repository title", + "search", + ), + ).not.toThrow(); + }); + it.each([ [ "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n This payload mentions githits code read but has no locator", @@ -113,6 +126,19 @@ Next: shorten or broaden query; use githits code grep.`; "1 result\n\n[1] page-1 [docs page] npm:express - README\n" + " githits docs read --lines 1-10", ], + [ + "1 result\n\n[1] page-1 [docs page] npm:express -\n" + + " README without a source locator", + ], + [ + "1 result\n\n[1] page ID unavailable [docs page] npm:express - docs.example.com/readme -\n" + + " Wrapped title without a page locator", + ], + [ + "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code] -\n" + + " Wrapped title without a locator", + ], + ["1 result\n\n[1] npm:express@5.2.1 lib/application.js [repo code] -"], ])("rejects incomplete or prose-only hit follow-ups", (text) => { expect(() => assertSearchTerminalText(text, "search")).toThrow( "missing result follow-up or next action", From b7364441cf87aa96f4184f6c20f3b1746dd7631b Mon Sep 17 00:00:00 2001 From: Juha Litola <juha.litola@iki.fi> Date: Fri, 28 Aug 2026 12:10:32 +0300 Subject: [PATCH 46/46] fix: accept repository title wrapping Teach both CLI and MCP smoke validators to recognize repository hit headers whose title continues on indented lines, and cover repository-only wrapped result pages. --- packages/mcp/src/smoke-test.test.ts | 15 +++++++++++++++ packages/mcp/src/smoke-test.ts | 2 +- scripts/cli-smoke.ts | 2 +- scripts/smoke-scripts.test.ts | 11 +++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 4aa5b33a..ed328eb9 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -409,6 +409,21 @@ describe("runMcpSmoke", () => { await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); }); + it("allows a wrapped repository title without a documentation hit", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "1 result | 1 repo code hit\n\n" + + "[1] npm:express@5.2.1 lib/application.js [repo code] -\n" + + " A long repository title", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + it.each([ [ "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n" + diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index d2b3123c..df1f50bf 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -349,7 +349,7 @@ function hasHumanSearchHitLocator(lines: string[]): boolean { ) { const title = match[3]; return title === undefined - ? !line.endsWith(" -") + ? !line.endsWith(" -") || hasWrappedHitTitle(lines, index) : title.trim().length > 0 || hasWrappedHitTitle(lines, index); } return false; diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 79b6400a..a3c8f433 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -529,7 +529,7 @@ function hasHumanSearchHitLocator(lines: string[]): boolean { ) { const title = match[3]; return title === undefined - ? !line.endsWith(" -") + ? !line.endsWith(" -") || hasWrappedHitTitle(lines, index) : title.trim().length > 0 || hasWrappedHitTitle(lines, index); } return false; diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index ebc346dd..be235ce1 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -112,6 +112,17 @@ Next: shorten or broaden query; use githits code grep.`; ).not.toThrow(); }); + it("accepts a wrapped repository title without a documentation hit", () => { + expect(() => + assertSearchTerminalText( + "1 result | 1 repo code hit\n\n" + + "[1] npm:express@5.2.1 lib/application.js [repo code] -\n" + + " A long repository title", + "search", + ), + ).not.toThrow(); + }); + it.each([ [ "1 result\n\n[1] npm:express@5.2.1 location unavailable [repo code]\n This payload mentions githits code read but has no locator",