diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index bbe3dde..6e9e23e 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -29,7 +29,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C | `pkg_deps` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_importers?`, `max_depth?`, `format?` | Direct runtime dependency list by default with resolved versions. Non-runtime groups are hidden with an MCP-native hint (`pass lifecycle="all"`). Use `lifecycle: "runtime"` for explicit runtime-only, a concrete non-runtime lifecycle for runtime plus matching groups, or `lifecycle: "all"` for all available groups. `max_depth` requests capped transitive output with aggregate edge counts, the preprocessed install footprint, typed conflicts and circular-dependency cycles; opt into importer provenance with `include_importers`. Pass `format: "json"` for the lean structured envelope. | | `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `omit_bodies?`, `verbose?`, `body_lines?`, `format?` | Release notes or changelog entries for a package or GitHub repo. Example: `{registry:"npm", package_name:"express", limit:2}`. Defaults to compact text with newest-first entries and 10-line body previews; set `body_lines` to tune text previews, `verbose:true` for full text bodies, `omit_bodies:true` for a lean timeline, or `format:"json"` for the complete envelope. `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) is mutually exclusive. | | `pkg_upgrade_review` | `registry?`, `package_name?`, `current_version?`, `target_version?`, `packages?`, `skip_transitive_security?`, `include_dependency_issues?`, `min_severity?`, `verbose?`, `format?` | Evidence for dependency upgrades. Accepts a single package or repeatable batch, compares current vs target direct vulnerabilities, changelog range evidence, target deprecation metadata, peer dependency changes, dependency changes, and transitive security evidence by default. `skip_transitive_security:true` disables transitive vulnerability evidence when latency matters. Reports facts only; callers decide whether to accept the upgrade. | -| `code_files` | `target`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion, targetResolution?}` in JSON mode. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref?` (repo, omitted ref means default branch intent). Selectors (`path`, `path_prefix`, `globs`) are OR-ed; the other filters intersect on top. `INDEXING` errors include immediate retry candidates in `details.availableVersions` / `details.availableRefs` when available. `format` defaults to `text-v1` (paths-only listing); pass `format: "json"` for the structured envelope. | +| `code_files` | `target`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion, targetResolution?}` in JSON mode. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref?` (repo, omitted ref means default branch intent). Selectors (`path`, `path_prefix`, `globs`) are OR-ed; the other filters intersect on top. `INDEXING` errors include immediate retry candidates in `details.availableVersions` / `details.availableRefs` when available; repository ref suggestions use `suggestedRefs` and are not immediate retry guarantees. `format` defaults to `text-v1` (paths-only listing); pass `format: "json"` for the structured envelope. | | `code_read` | `target`, `path`, `start_line?`, `end_line?`, `wait_timeout_ms?`, `format?` | Read a file from an indexed dependency. `target` accepts the structured object or compact string (`npm:react@18.2.0`, `github:facebook/react#HEAD`, `github.com/facebook/react#HEAD`, `https://github.com/facebook/react#HEAD`, `github:facebook/react@HEAD`, or any repo form without `#ref`/`@ref` for default branch intent). User-facing output canonicalizes repo targets as `github:owner/repo#ref` so refs can contain `@` safely. Package compact strings require an explicit registry prefix. **MCP per-call span cap: 150 lines** — broader requests (or no range) are silently truncated to the first 150 lines from the caller's start, with a hint explaining the cap and the original request. Defaults to `text-v1` with line-numbered content; pass `format: "json"` for the structured envelope. Binary files set `isBinary: true` and omit `content`; `targetResolution` may explain fallback/indexing provenance. On `NOT_FOUND` / `FILE_NOT_FOUND` call `code_files` to discover the actual path. The cap is MCP-only; the CLI command `githits code read` honors arbitrary ranges. | | `code_grep` | `target`, `pattern`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `pattern_type?`, `case_sensitive?`, `exclude_doc_files?`, `exclude_test_files?`, `context_lines?`, `context_lines_before?`, `context_lines_after?`, `max_matches?`, `max_matches_per_file?`, `cursor?`, `symbol_fields?`, `wait_timeout_ms?`, `format?` | Deterministic text grep over indexed dependency or repository source. Defaults to literal, ASCII case-insensitive matching across the whole target; non-ASCII letters match case-sensitively. Narrow with `path`, `path_prefix`, `globs`, or `extensions`. `pattern_type: "regex"` uses RE2 syntax; whole-target regexes must include at least one literal substring for index pre-filtering. Returns matches plus pagination and scan counters; `symbol_fields` hydrates enclosing symbol metadata on each match. `format` defaults to `text-v1` (matches grouped by file, grep -A/-B notation for context); pass `format: "json"` for the structured envelope. | @@ -143,7 +143,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`, or `unavailable`), `freshnessReason`, `indexingRef`, `availableVersions`, and `availableRefs`. Existing `indexedVersion`, `resolution`, and locator fields remain served-identity compatibility fields. Text mode renders actionable notes such as `using recent index`, `indexing fresh target`, or `target unavailable`; JSON mode carries the structured object. +`targetResolution` is additive provenance. It explains requested, resolved-requested, and served artifacts plus `freshness` (`current`, `fallback_recent`, `indexing`, or `unavailable`), `freshnessReason`, `indexingRef`, `availableVersions`, `availableRefs`, and `suggestedRefs`. `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 index`, `indexing fresh target`, `target unavailable`, `queryable now`, or `suggested refs`; JSON mode carries the structured object. ### Indexing lifecycle (shared across `code_files`, `code_read`, `code_grep`) @@ -163,7 +163,7 @@ All three code-navigation tools share the same indexing-retry contract. The stat } ``` -`details.availableVersions` and `details.availableRefs` are populated when the backend returned already-indexed artifacts alongside the sentinel. Agents can pick one to retry against immediately without waiting. When `details.targetResolution` is present, it is explanatory provenance only; follow-up commands still use served locators / legacy served fields rather than reconstructing targets from the originally requested identity. +`details.availableVersions` and `details.availableRefs` are populated when the backend returned already-indexed artifacts alongside the sentinel. Agents can pick one to retry against immediately without waiting. `details.suggestedRefs` appears on `REF_NOT_FOUND` and inside `details.targetResolution` when the backend has fuzzy repository-ref candidates; these are suggestions only, not immediate retry guarantees. When `details.targetResolution` is present, it is explanatory provenance only; follow-up commands still use served locators / legacy served fields rather than reconstructing targets from the originally requested identity. **Retry default**: `DEFAULT_WAIT_TIMEOUT_MS = 20_000` (shared, defined in `packages/mcp/src/shared/code-navigation-defaults.ts`). Applied inside each request builder so both CLI and MCP surfaces get the same default by construction. CLI's `--wait ` and MCP's `wait_timeout_ms` override. diff --git a/packages/core-internal/src/services/code-navigation-service.test.ts b/packages/core-internal/src/services/code-navigation-service.test.ts index 0fd2933..054c20e 100644 --- a/packages/core-internal/src/services/code-navigation-service.test.ts +++ b/packages/core-internal/src/services/code-navigation-service.test.ts @@ -222,6 +222,7 @@ describe("CodeNavigationServiceImpl", () => { indexingRef: "ref_xyz", availableVersions: [], availableRefs: [{ ref: "main" }, { ref: "v4.18.2" }], + suggestedRefs: [{ ref: "express-v4.18.2" }], }, }, }, @@ -252,6 +253,9 @@ describe("CodeNavigationServiceImpl", () => { { ref: "v4.18.2" }, ]); expect(typed.targetResolution?.freshness).toBe("indexing"); + expect(typed.targetResolution?.suggestedRefs).toEqual([ + { ref: "express-v4.18.2" }, + ]); } }); @@ -306,8 +310,71 @@ describe("CodeNavigationServiceImpl", () => { expect(result.indexedVersion).toBe("v5.2.1"); expect(bodies).toHaveLength(2); + expect(bodies[0]).toContain("suggestedRefs"); expect(bodies[0]).toContain("availableRefs"); - expect(bodies[1]).not.toContain("availableRefs"); + expect(bodies[1]).not.toContain("suggestedRefs"); + expect(bodies[1]).toContain("availableRefs"); + }); + + it("falls back again when targetResolution availableRefs is also unsupported", async () => { + const bodies: string[] = []; + globalThis.fetch = mock((_, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + const message = + bodies.length === 1 + ? 'Cannot query field "suggestedRefs" on type "TargetResolution".' + : 'Cannot query field "availableRefs" on type "TargetResolution".'; + if (bodies.length < 3) { + return Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message, + extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, + }, + ], + }), + { status: 200 }, + ), + ); + } + return Promise.resolve( + new Response( + JSON.stringify({ + data: { + listRepoFiles: { + files: [], + total: 0, + hasMore: false, + indexedVersion: "v5.2.1", + resolution: null, + diagnostics: null, + codeIndexState: "CURRENT", + }, + }, + }), + { status: 200 }, + ), + ); + }) as unknown as typeof fetch; + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + const result = await service.listFiles({ + target: { registry: "NPM", packageName: "express" }, + }); + + expect(result.indexedVersion).toBe("v5.2.1"); + expect(bodies).toHaveLength(3); + expect(bodies[0]).toContain("suggestedRefs"); + expect(bodies[1]).not.toContain("suggestedRefs"); + expect(bodies[1]).toContain("availableRefs"); + expect(bodies[2]).not.toContain("suggestedRefs"); + expect(bodies[2]).not.toContain("availableRefs"); }); it("surfaces diagnostics.hint on listFiles empty responses", async () => { @@ -865,7 +932,7 @@ describe("CodeNavigationServiceImpl", () => { } }); - it("classifies GraphQL REF_NOT_FOUND with available ref suggestions", async () => { + it("classifies GraphQL REF_NOT_FOUND with indexed refs and suggested refs", async () => { mockFetch(() => Promise.resolve( new Response( @@ -873,13 +940,14 @@ describe("CodeNavigationServiceImpl", () => { errors: [ { message: - "Repository ref cannot be resolved for openai/codex@1.2.3. Did you mean codex@1.2.3, v1.2.3?", + "Repository ref cannot be resolved for github:openai/codex#1.2.3. Did you mean codex@1.2.3, v1.2.3?", extensions: { code: "REF_NOT_FOUND", retryable: false, repo_url: "https://github.com/openai/codex", git_ref: "1.2.3", - available_refs: [ + available_refs: [{ ref: "main", version: null }], + suggested_refs: [ { ref: "codex@1.2.3", version: null }, { ref: "v1.2.3", version: null }, ], @@ -910,6 +978,9 @@ describe("CodeNavigationServiceImpl", () => { expect(typed.repoUrl).toBe("https://github.com/openai/codex"); expect(typed.requestedRef).toBe("1.2.3"); expect(typed.availableRefs).toEqual([ + { ref: "main", version: undefined }, + ]); + expect(typed.suggestedRefs).toEqual([ { ref: "codex@1.2.3", version: undefined }, { ref: "v1.2.3", version: undefined }, ]); diff --git a/packages/core-internal/src/services/code-navigation-service.ts b/packages/core-internal/src/services/code-navigation-service.ts index cfffb19..1a093b9 100644 --- a/packages/core-internal/src/services/code-navigation-service.ts +++ b/packages/core-internal/src/services/code-navigation-service.ts @@ -117,6 +117,7 @@ export interface TargetResolutionIdentity { } export type AvailableRef = AvailableVersion; +export type SuggestedRef = AvailableVersion; export interface TargetResolution { requested?: TargetResolutionIdentity; @@ -127,6 +128,7 @@ export interface TargetResolution { indexingRef?: string; availableVersions: AvailableVersion[]; availableRefs: AvailableRef[]; + suggestedRefs?: SuggestedRef[]; } export type UnifiedSearchSource = "AUTO" | "DOCS" | "CODE" | "SYMBOL"; @@ -259,6 +261,7 @@ export interface UnifiedSearchProgressTarget { targetResolution?: TargetResolution; availableVersions?: AvailableVersion[]; availableRefs?: AvailableRef[]; + suggestedRefs?: SuggestedRef[]; } export interface UnifiedSearchRequestedTarget { @@ -580,6 +583,7 @@ export class CodeNavigationRefNotFoundError extends Error { public readonly repoUrl: string | undefined, public readonly requestedRef: string | undefined, public readonly availableRefs: AvailableRef[] | undefined, + public readonly suggestedRefs: SuggestedRef[] | undefined, ) { super(message); this.name = "CodeNavigationRefNotFoundError"; @@ -640,6 +644,18 @@ availableRefs { ref }`; +const TARGET_RESOLUTION_SUGGESTED_REFS_SELECTION = ` +suggestedRefs { + version + ref +}`; + +const DISCOVERY_TARGET_PROGRESS_SUGGESTED_REFS_SELECTION = ` +suggestedRefs { + version + ref +}`; + const TARGET_RESOLUTION_SELECTION = ` targetResolution { requested { @@ -677,6 +693,7 @@ targetResolution { ref } ${TARGET_RESOLUTION_AVAILABLE_REFS_SELECTION} + ${TARGET_RESOLUTION_SUGGESTED_REFS_SELECTION} }`; const CODE_CONTEXT_AVAILABLE_VERSIONS_SELECTION = ` @@ -693,7 +710,8 @@ availableVersions { availableRefs { version ref -}`; +} +${DISCOVERY_TARGET_PROGRESS_SUGGESTED_REFS_SELECTION}`; const UNIFIED_SEARCH_QUERY = ` query UnifiedSearch( @@ -1035,6 +1053,7 @@ const targetResolutionSchema = z indexingRef: z.string().nullable().optional(), availableVersions: z.array(availableVersionSchema).nullable().optional(), availableRefs: z.array(availableVersionSchema).nullable().optional(), + suggestedRefs: z.array(availableVersionSchema).nullable().optional(), }) .nullable() .optional(); @@ -1162,6 +1181,7 @@ const unifiedSearchProgressTargetSchema = z.object({ targetResolution: targetResolutionSchema, availableVersions: z.array(availableVersionSchema).nullable().optional(), availableRefs: z.array(availableVersionSchema).nullable().optional(), + suggestedRefs: z.array(availableVersionSchema).nullable().optional(), }); const unifiedSearchRequestedTargetSchema = z.object({ @@ -1963,6 +1983,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { ? extensions.gitRef : undefined, parseAvailableRefs(extensions), + parseSuggestedRefs(extensions), ); case "NOT_FOUND": @@ -2206,6 +2227,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { targetResolution: normaliseTargetResolution(target.targetResolution), availableVersions: normaliseAvailableVersions(target.availableVersions), availableRefs: normaliseAvailableVersions(target.availableRefs), + suggestedRefs: normaliseAvailableVersions(target.suggestedRefs), })), expiresAt: progress.expiresAt ?? undefined, }; @@ -2571,11 +2593,24 @@ function parseDetail(body: string): string | undefined { } function buildTargetResolutionFallbackQueries(query: string): string[] { + const withoutSuggestedRefs = query + .replaceAll(TARGET_RESOLUTION_SUGGESTED_REFS_SELECTION, "") + .replaceAll(DISCOVERY_TARGET_PROGRESS_SUGGESTED_REFS_SELECTION, ""); const candidates = [ - query.replaceAll(TARGET_RESOLUTION_AVAILABLE_REFS_SELECTION, ""), - query.replaceAll(DISCOVERY_TARGET_PROGRESS_RETRY_SELECTION, ""), - query.replaceAll(CODE_CONTEXT_AVAILABLE_VERSIONS_SELECTION, ""), - query + withoutSuggestedRefs, + withoutSuggestedRefs.replaceAll( + TARGET_RESOLUTION_AVAILABLE_REFS_SELECTION, + "", + ), + withoutSuggestedRefs.replaceAll( + DISCOVERY_TARGET_PROGRESS_RETRY_SELECTION, + "", + ), + withoutSuggestedRefs.replaceAll( + CODE_CONTEXT_AVAILABLE_VERSIONS_SELECTION, + "", + ), + withoutSuggestedRefs .replaceAll(TARGET_RESOLUTION_SELECTION, "") .replaceAll(DISCOVERY_TARGET_PROGRESS_RETRY_SELECTION, "") .replaceAll(CODE_CONTEXT_AVAILABLE_VERSIONS_SELECTION, ""), @@ -2655,6 +2690,13 @@ function parseAvailableRefs( return parseAvailableArtifacts(raw); } +function parseSuggestedRefs( + extensions: Record | undefined, +): SuggestedRef[] | undefined { + const raw = extensions?.suggested_refs ?? extensions?.suggestedRefs; + return parseAvailableArtifacts(raw); +} + function parseTargetResolution( extensions: Record | undefined, ): TargetResolution | undefined { @@ -2708,6 +2750,7 @@ function normaliseTargetResolution( availableVersions: normaliseAvailableVersions(resolution.availableVersions) ?? [], availableRefs: normaliseAvailableVersions(resolution.availableRefs) ?? [], + suggestedRefs: normaliseAvailableVersions(resolution.suggestedRefs) ?? [], }; } diff --git a/packages/mcp/src/shared/code-navigation-error-map.test.ts b/packages/mcp/src/shared/code-navigation-error-map.test.ts index 759607e..6fa8e5f 100644 --- a/packages/mcp/src/shared/code-navigation-error-map.test.ts +++ b/packages/mcp/src/shared/code-navigation-error-map.test.ts @@ -101,29 +101,49 @@ describe("mapCodeNavigationError", () => { it("classifies CodeNavigationRefNotFoundError as REF_NOT_FOUND with suggestions", () => { const err = new CodeNavigationRefNotFoundError( - "Repository ref cannot be resolved for openai/codex@1.2.3.", + "Repository ref cannot be resolved for github:openai/codex#1.2.3.", "https://github.com/openai/codex", "1.2.3", + [{ ref: "main" }], [{ ref: "codex@1.2.3" }, { ref: "v1.2.3" }], ); expect(mapCodeNavigationError(err)).toEqual({ code: "REF_NOT_FOUND", message: - "Repository ref cannot be resolved for openai/codex@1.2.3. Did you mean codex@1.2.3, v1.2.3?", + "Repository ref cannot be resolved for github:openai/codex#1.2.3. Did you mean codex@1.2.3, v1.2.3?", retryable: false, details: { repoUrl: "https://github.com/openai/codex", requestedRef: "1.2.3", - availableRefs: [{ ref: "codex@1.2.3" }, { ref: "v1.2.3" }], + availableRefs: [{ ref: "main" }], + suggestedRefs: [{ ref: "codex@1.2.3" }, { ref: "v1.2.3" }], }, }); }); + it("does not treat indexed refs as REF_NOT_FOUND suggestions", () => { + const err = new CodeNavigationRefNotFoundError( + "Repository ref cannot be resolved.", + undefined, + undefined, + [{ ref: "main" }], + undefined, + ); + + expect(mapCodeNavigationError(err)).toEqual({ + code: "REF_NOT_FOUND", + message: "Repository ref cannot be resolved.", + retryable: false, + details: { availableRefs: [{ ref: "main" }] }, + }); + }); + it("does not duplicate backend REF_NOT_FOUND suggestions", () => { const err = new CodeNavigationRefNotFoundError( "Repository ref cannot be resolved. Did you mean v1.2.3?", undefined, undefined, + undefined, [{ ref: "v1.2.3" }], ); expect(mapCodeNavigationError(err).message).toBe( @@ -409,7 +429,13 @@ describe("mapCodeNavigationError debug instrumentation", () => { new CodeNavigationTargetNotFoundError("x"), new CodeNavigationFileNotFoundError("x", "some/path"), new CodeNavigationIndexingError("x"), - new CodeNavigationRefNotFoundError("x", undefined, undefined, undefined), + new CodeNavigationRefNotFoundError( + "x", + undefined, + undefined, + undefined, + undefined, + ), new CodeNavigationUnresolvableError("x"), new CodeNavigationAccessError("x"), new AuthenticationError("x"), diff --git a/packages/mcp/src/shared/code-navigation-error-map.ts b/packages/mcp/src/shared/code-navigation-error-map.ts index 9853241..c6c4fea 100644 --- a/packages/mcp/src/shared/code-navigation-error-map.ts +++ b/packages/mcp/src/shared/code-navigation-error-map.ts @@ -19,6 +19,7 @@ import { CodeNavigationVersionNotFoundError, debugLog, MalformedCodeNavigationResponseError, + type SuggestedRef, type TargetResolution, } from "@githits/core-internal"; import { AuthRequiredError } from "./require-auth.js"; @@ -45,6 +46,7 @@ export interface MappedErrorDetails { action?: string; availableVersions?: AvailableVersion[]; availableRefs?: AvailableRef[]; + suggestedRefs?: SuggestedRef[]; targetResolution?: TargetResolution; indexingRef?: string; status?: number; @@ -153,9 +155,12 @@ function classify(error: unknown): MappedError { if (error.availableRefs && error.availableRefs.length > 0) { details.availableRefs = error.availableRefs; } + if (error.suggestedRefs && error.suggestedRefs.length > 0) { + details.suggestedRefs = error.suggestedRefs; + } return { code: "REF_NOT_FOUND", - message: addRefSuggestions(error.message, error.availableRefs), + message: addRefSuggestions(error.message, error.suggestedRefs), retryable: false, details: Object.keys(details).length > 0 ? details : undefined, }; diff --git a/packages/mcp/src/shared/target-resolution.test.ts b/packages/mcp/src/shared/target-resolution.test.ts index 04c37e3..b26d080 100644 --- a/packages/mcp/src/shared/target-resolution.test.ts +++ b/packages/mcp/src/shared/target-resolution.test.ts @@ -86,6 +86,20 @@ describe("target-resolution helpers", () => { ).toBe("queryable now: versions=1.2.3@v1.2.3 | refs=main"); }); + it("renders suggested refs separately from immediate retry candidates", () => { + const notes = buildTargetResolutionNotes({ + freshness: "unavailable", + availableVersions: [], + availableRefs: [{ ref: "main" }], + suggestedRefs: [{ ref: "pkg@1.2.3" }, { ref: "v1.2.3" }], + }); + + expect(notes).toContain("queryable now: refs=main"); + expect(notes).toContain( + "suggested refs (may need indexing): pkg@1.2.3,v1.2.3", + ); + }); + it("suppresses identical current provenance", () => { expect( buildTargetResolutionNotes({ diff --git a/packages/mcp/src/shared/target-resolution.ts b/packages/mcp/src/shared/target-resolution.ts index a2bd939..a837afe 100644 --- a/packages/mcp/src/shared/target-resolution.ts +++ b/packages/mcp/src/shared/target-resolution.ts @@ -25,6 +25,7 @@ export interface LeanTargetResolution { indexingRef?: string; availableVersions: LeanAvailableArtifact[]; availableRefs: LeanAvailableArtifact[]; + suggestedRefs?: LeanAvailableArtifact[]; } export interface TargetResolutionRetryCandidates { @@ -32,6 +33,7 @@ export interface TargetResolutionRetryCandidates { indexingRef?: string; availableVersions?: LeanAvailableArtifact[]; availableRefs?: LeanAvailableArtifact[]; + suggestedRefs?: LeanAvailableArtifact[]; } export function projectTargetResolution( @@ -55,6 +57,7 @@ export function projectTargetResolution( ...(resolution.indexingRef ? { indexingRef: resolution.indexingRef } : {}), availableVersions: resolution.availableVersions.map(projectArtifact), availableRefs: resolution.availableRefs.map(projectArtifact), + suggestedRefs: (resolution.suggestedRefs ?? []).map(projectArtifact), }; } @@ -119,6 +122,8 @@ export function buildTargetResolutionNotes( const candidates = buildRetryCandidateLine(resolution); if (candidates) lines.push(candidates); + const suggestions = buildSuggestedRefsLine(resolution); + if (suggestions) lines.push(suggestions); return lines; } @@ -140,10 +145,24 @@ export function buildRetryCandidateLine( return parts.length > 0 ? `queryable now: ${parts.join(" | ")}` : undefined; } +export function buildSuggestedRefsLine( + resolution: LeanTargetResolution | undefined, +): string | undefined { + const refs = resolution?.suggestedRefs ?? []; + if (refs.length === 0) return undefined; + return `suggested refs (may need indexing): ${refs + .map(formatArtifact) + .join(",")}`; +} + export function buildResolutionFromRetryCandidates( target: TargetResolutionRetryCandidates, ): LeanTargetResolution | undefined { - if (!target.availableVersions?.length && !target.availableRefs?.length) { + if ( + !target.availableVersions?.length && + !target.availableRefs?.length && + !target.suggestedRefs?.length + ) { return undefined; } return { @@ -151,6 +170,7 @@ export function buildResolutionFromRetryCandidates( indexingRef: target.indexingRef, availableVersions: target.availableVersions ?? [], availableRefs: target.availableRefs ?? [], + suggestedRefs: target.suggestedRefs ?? [], }; } diff --git a/packages/mcp/src/shared/unified-search-response.test.ts b/packages/mcp/src/shared/unified-search-response.test.ts index f30d7af..903f8a1 100644 --- a/packages/mcp/src/shared/unified-search-response.test.ts +++ b/packages/mcp/src/shared/unified-search-response.test.ts @@ -383,6 +383,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { indexingRef: "idx_123", availableVersions: [], availableRefs: [{ ref: "main" }, { ref: "v4.18.2" }], + suggestedRefs: [{ ref: "express-v4.18.2" }], }, }, ], @@ -401,6 +402,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { ); expect(payload.warnings?.join("\n")).toContain("using recent index"); expect(payload.warnings?.join("\n")).toContain("queryable now"); + expect(payload.warnings?.join("\n")).toContain("suggested refs"); }); it("canonicalizes source-status repository labels", () => { @@ -620,6 +622,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { indexingRef: "idx_123", availableVersions: [], availableRefs: [{ ref: "main" }, { ref: "v1.2.3" }], + suggestedRefs: [{ ref: "foo-v1.2.3" }], }, }, ], @@ -637,7 +640,11 @@ describe("buildUnifiedSearchSuccessPayload", () => { expect( payload.progress?.targets?.[0]?.targetResolution?.availableRefs, ).toEqual([{ ref: "main" }, { ref: "v1.2.3" }]); + expect( + payload.progress?.targets?.[0]?.targetResolution?.suggestedRefs, + ).toEqual([{ ref: "foo-v1.2.3" }]); expect(payload.warnings?.join("\n")).toContain("queryable now"); + expect(payload.warnings?.join("\n")).toContain("suggested refs"); }); }); @@ -1010,22 +1017,24 @@ describe("buildUnifiedSearchErrorPayload", () => { it("includes REF_NOT_FOUND ref suggestions in message and details", () => { const payload = buildUnifiedSearchErrorPayload( new CodeNavigationRefNotFoundError( - "Repository ref cannot be resolved for openai/codex@1.2.3.", + "Repository ref cannot be resolved for github:openai/codex#1.2.3.", "https://github.com/openai/codex", "1.2.3", + [{ ref: "main" }], [{ ref: "codex@1.2.3" }, { ref: "v1.2.3" }], ), ); expect(payload).toEqual({ error: - "Repository ref cannot be resolved for openai/codex@1.2.3. Did you mean codex@1.2.3, v1.2.3?", + "Repository ref cannot be resolved for github:openai/codex#1.2.3. Did you mean codex@1.2.3, v1.2.3?", code: "REF_NOT_FOUND", retryable: false, details: { repoUrl: "https://github.com/openai/codex", requestedRef: "1.2.3", - availableRefs: [{ ref: "codex@1.2.3" }, { ref: "v1.2.3" }], + availableRefs: [{ ref: "main" }], + suggestedRefs: [{ ref: "codex@1.2.3" }, { ref: "v1.2.3" }], }, }); }); diff --git a/packages/mcp/src/shared/unified-search-response.ts b/packages/mcp/src/shared/unified-search-response.ts index 805446b..e801ead 100644 --- a/packages/mcp/src/shared/unified-search-response.ts +++ b/packages/mcp/src/shared/unified-search-response.ts @@ -112,6 +112,7 @@ export interface UnifiedSearchProgressPayload { targetResolution?: LeanTargetResolution; availableVersions?: LeanAvailableArtifact[]; availableRefs?: LeanAvailableArtifact[]; + suggestedRefs?: LeanAvailableArtifact[]; }>; expiresAt?: string; next?: string; @@ -578,6 +579,9 @@ function compactProgressTarget( if (target.availableRefs?.length) { payload.availableRefs = target.availableRefs; } + if (target.suggestedRefs?.length) { + payload.suggestedRefs = target.suggestedRefs; + } return Object.keys(payload).length > 0 ? payload : undefined; } diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index a21a5aa..a3158fc 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -277,6 +277,7 @@ export function formatProgressTarget(target: { 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}`); diff --git a/packages/mcp/src/tools/grep-repo.ts b/packages/mcp/src/tools/grep-repo.ts index 9c27c89..9e85219 100644 --- a/packages/mcp/src/tools/grep-repo.ts +++ b/packages/mcp/src/tools/grep-repo.ts @@ -81,7 +81,12 @@ const schema: ZodRawShape = { .array(z.enum(GREP_REPO_SYMBOL_FIELDS)) .optional() .describe(GREP_REPO_SYMBOL_FIELDS_NOTE), - wait_timeout_ms: z.number().optional(), + wait_timeout_ms: z + .number() + .optional() + .describe( + "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass an already-indexed version/ref from `details.availableVersions` / `details.availableRefs`; `suggestedRefs` are fuzzy hints and may need indexing first.", + ), format: z .enum(["json", "text", "text-v1"]) .optional() @@ -96,7 +101,8 @@ const DESCRIPTION = "Use `search` for discovery instead. " + "Whole-target grep is the default — narrow with `path`, `path_prefix`, `globs`, or `extensions` to keep responses small. " + "Each match's `filePath` (or text file heading) chains into `code_read.path`; pick a window around `match.line` for `code_read.start_line` / `end_line`. " + - "When fresh data is not ready within the wait window, responses may include `targetResolution` provenance and immediately-queryable alternatives in error details." + + "When fresh data is not ready within the wait window, responses may include `targetResolution` provenance and immediately-queryable alternatives in error details. " + + "`availableVersions` and `availableRefs` are already indexed/queryable; `suggestedRefs` are fuzzy ref hints and may need indexing first." + `\n\n${CODE_GREP_GUARDRAIL}`; export function createGrepRepoTool( diff --git a/packages/mcp/src/tools/list-files.ts b/packages/mcp/src/tools/list-files.ts index d62a781..dce66cb 100644 --- a/packages/mcp/src/tools/list-files.ts +++ b/packages/mcp/src/tools/list-files.ts @@ -98,7 +98,7 @@ const schema: ZodRawShape = { .number() .optional() .describe( - "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass a version/ref from `details.availableVersions` / `details.availableRefs`.", + "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass an already-indexed version/ref from `details.availableVersions` / `details.availableRefs`; `suggestedRefs` are fuzzy hints and may need indexing first.", ), format: z .enum(["json", "text", "text-v1"]) @@ -123,7 +123,9 @@ const DESCRIPTION = "language, fileType, byteSize}], resolution, indexedVersion}`. " + "When fresh data is not ready within the wait window, responses may " + "include `targetResolution` provenance and immediately-queryable " + - "alternatives. On an `INDEXING` error envelope, retry with a longer " + + "alternatives. `availableVersions` and `availableRefs` are already " + + "indexed/queryable; `suggestedRefs` are fuzzy ref hints and may need " + + "indexing first. On an `INDEXING` error envelope, retry with a longer " + "`wait_timeout_ms` or use a version/ref from `details.availableVersions` " + "/ `details.availableRefs`."; diff --git a/packages/mcp/src/tools/read-file.ts b/packages/mcp/src/tools/read-file.ts index 9b078a1..dba0add 100644 --- a/packages/mcp/src/tools/read-file.ts +++ b/packages/mcp/src/tools/read-file.ts @@ -62,7 +62,7 @@ const schema: ZodRawShape = { .number() .optional() .describe( - "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass a version/ref from `details.availableVersions` / `details.availableRefs`.", + "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass an already-indexed version/ref from `details.availableVersions` / `details.availableRefs`; `suggestedRefs` are fuzzy hints and may need indexing first.", ), format: z .enum(["json", "text", "text-v1"]) @@ -87,8 +87,11 @@ export const DESCRIPTION: string = "scope) or `target.repo_url` + optional `target.git_ref` (repo scope), " + "mutually exclusive. When fresh data is not ready within the wait " + "window, responses may include `targetResolution` provenance and " + - "immediately-queryable alternatives. On `INDEXING` retry with a " + - "longer `wait_timeout_ms` or use a version/ref from error details. " + + "immediately-queryable alternatives. `availableVersions` and " + + "`availableRefs` are already indexed/queryable; `suggestedRefs` " + + "are fuzzy ref hints and may need indexing first. On `INDEXING` " + + "retry with a longer `wait_timeout_ms` or use a version/ref from " + + "error details. " + "On `NOT_FOUND` / `FILE_NOT_FOUND` call " + "`code_files` to discover the actual path." + `\n\n${CODE_READ_GUARDRAIL}`;