From 5a22ae6bc8bae6cadd25aa5a6635a3c219d17b82 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:29:03 -0700 Subject: [PATCH] fix(mcp)!: return a compact binary_session status by default (#7127) * fix(mcp)!: return a compact binary_session status by default binary_session {} with no target open returned about 66 KB, most of it provider rejection diagnostics and candidate capability sets that the skill's recovery flow re-reads on every retry. 6.8.0 grew it by 21% because the shared regex capability limitation began repeating setting bounds and step-by-step recovery for every search operation of every provider (#6099, #6267, #6301, #7049) and IDA gained that limitation for its two search operations (#4862). The regex limitation now states the effective deadline and heap, their sources and where to change them; the deadline and heap errors keep the full recovery. binary_session gains detail: summary (default) or complete. The summary keeps open state, target identity, the binding, route capabilities, run, activity and health status, each candidate's availability and target-support status, code and reason, and every tool's availability, reason, remediation and modes, and lists the fields it leaves out in omitted_fields. Complete detail returns the previous shape. The CLI capabilities and providers commands keep complete provider facts through the same application projection. BREAKING CHANGE: binary_session {} no longer returns analysis_provider_candidates[].availability.diagnostics, target_support.diagnostics or capabilities, or tool_availability group surface, effects and annotations. Pass {"detail": "complete"} to get them. Fixes #7109 Co-Authored-By: Claude Opus 5.5 * fix(mcp): key binary_session output fields by detail The output schema accepted detail: "complete" with summary candidates or tool groups, and a summary without omitted_fields, so detail did not tell callers which fields existed. Each session kind is now crossed with a detail-specific shape: a summary requires omitted_fields and summary-only candidates and tool groups; complete requires diagnostics, candidate capabilities and static tool flags and forbids omitted_fields. Both the canonical schema and the SDK-validated advertised schema reject mismatched shapes. The verifier helper accepts summary candidates only for a validated MCP summary; CLI provider output still requires candidate capabilities. Regex limitation tests check values, sources and setting names instead of whole sentences. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../skills/reverse-engineer-anything/SKILL.md | 16 +- .../references/connection-and-recovery.md | 2 +- .../references/native-and-artifacts.md | 21 +- docs/mcp-contracts.md | 28 +- scripts/lib/mcp-verifier-results.mjs | 14 +- src/application/CapabilityInventory.test.ts | 42 +++ src/application/CapabilityInventory.ts | 57 ++-- src/application/binary/BinarySession.ts | 8 +- src/application/binary/BinarySessionPort.ts | 7 +- .../binary/BinarySessionStatus.test.ts | 139 ++++++++- src/application/binary/BinarySessionStatus.ts | 55 +++- src/contracts/sessionStatusContract.ts | 23 ++ src/contracts/sessionToolContracts.ts | 2 +- src/contracts/toolOutputSchemaGroups.ts | 52 ++-- src/contracts/toolOutputSchemaPrimitives.ts | 198 +++++++++--- src/process/RegexMatcher.test.ts | 49 ++- src/process/RegexMatcher.ts | 22 +- src/server/promptCompletion.ts | 5 +- src/server/registerSessionStatusTool.ts | 20 +- tests/acceptance/applications/runtime.test.ts | 4 +- tests/acceptance/sqliteDatabaseCli.test.ts | 6 +- tests/boundary/mcp/electronMcp.test.ts | 2 +- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 25 +- tests/boundary/mcp/jebMcp.test.ts | 13 +- .../boundary/mcp/providerSelectionMcp.test.ts | 281 +++++++++++++++++- tests/boundary/mcp/serverIdentity.test.ts | 2 +- tests/boundary/mcp/sessionMcp.test.ts | 6 +- .../process/mcpVerifierResults.test.ts | 70 +++++ 28 files changed, 985 insertions(+), 184 deletions(-) create mode 100644 tests/boundary/process/mcpVerifierResults.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index d75f5548bb..d173763599 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -374,12 +374,16 @@ native target whose analysis tool operates on the active target. Do not call an unadvertised tool; inspect `binary_session` with `{}` and its `result.tool_availability` for availability reasons and remediation. Expand each group's `names` into entries, inheriting the inventory-level client requirements -unless the group overrides them. In its closed effect and annotation vocabularies, -listed flags are true and omitted known flags are false. The grouped name union -is the complete tool inventory; use `tools/list` for canonical tool schemas. -Provider candidate facts describe possible providers, while top-level session -capabilities describe the current route and `analysis_provider_binding` names the -selected provider. Do not treat those scopes as interchangeable. +unless the group overrides them. The grouped name union is the complete tool +inventory; use `tools/list` for canonical tool schemas. The default summary keeps +each provider candidate's availability and target-support status, code and +reason; `omitted_fields` lists what it leaves out. Request `{"detail": "complete"}` +only when you need provider diagnostics, candidate capability sets, or tool +surface, effect and annotation flags; in those closed flag vocabularies, listed +flags are true and omitted known flags are false. Provider candidate facts +describe possible providers, while top-level session capabilities describe the +current route and `analysis_provider_binding` names the selected provider. Do +not treat those scopes as interchangeable. For a Ghidra startup timeout on a large native target, follow [deadline recovery](references/native-and-artifacts.md#ghidra-startup-deadline-recovery) diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index 491ea89289..102f2abda4 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -22,7 +22,7 @@ does not establish which server an approval wildcard covers. See | Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. | | MCP connection dropped or REA could not start | If the connection drops, retry that call once only when it is safe to repeat or known not to have started. If it may already have changed the target, or the same failure returns, tell the user REA's connection failed and ask them to restart the MCP client. If REA could not start its connection, retry once, then tell the user and ask them to restart the MCP client. Do not retry a startup failure that returns the same unchanged result. Run `rea doctor` when REA could not start or tools stay missing after restart. Doctor cannot restore a live connection by itself. | | `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. | -| REA returns `provider_unavailable` or an unavailable tool entry | Read that operation's provider diagnostics or `binary_session.result.tool_availability`. Expand each group's tool names and inherit the shared client requirements unless overridden. Use the operation's availability, reason, and remediation; a complete tool catalog does not imply every operation is available. | +| REA returns `provider_unavailable` or an unavailable tool entry | Read that operation's provider diagnostics or `binary_session.result.tool_availability`. Expand each group's tool names and inherit the shared client requirements unless overridden. Use the operation's availability, reason, and remediation; a complete tool catalog does not imply every operation is available. Request `{"detail": "complete"}` only for provider diagnostics. | | `rea` is missing in a child shell, or Node is incompatible | Inspect the actual shell PATH and launcher/runtime. A prefix installation may be absent from a login shell's PATH; this does not establish a broken MCP registration. | If the client cannot launch its shell/sandbox helper or read the installed skill, diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 7a8f7c9aff..4b640cca84 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -323,18 +323,21 @@ file name when selecting that image only; otherwise loaded system images also match. Inspect retained locations and module paths before attributing a call. Use `binary_session` with no arguments to check the open target, selected -provider, and alignment. Its default `result.tool_availability` carries a -complete inventory grouped by shared facts. Expand each group's `names` and -inherit the inventory-level client requirements unless a group overrides them; -availability, reasons, remediation, modes, and flags remain inline. The -`effects` and `annotations` arrays name true flags from fixed vocabularies, so an -omitted known flag is false. `tools/list` remains the canonical source for full -tool schemas when target or provider state changes. +provider, and alignment. Its default summary `result.tool_availability` carries +the complete tool inventory grouped by current status. Expand each group's +`names` and inherit the inventory-level client requirements unless a group +overrides them; availability, reasons, remediation, and modes remain inline. +Provider candidates keep availability and target-support status, code, and +reason. `omitted_fields` names what the summary leaves out; `{"detail": +"complete"}` adds provider diagnostics, candidate capability sets, and tool +surface, `effects`, and `annotations`. Those arrays name true flags from fixed +vocabularies, so an omitted known flag is false. `tools/list` remains the +canonical source for full tool schemas when target or provider state changes. Provider capability groups carry provider identity and ordered limitations. Prepend each set's `shared_limitations` to a group's `limitations` to recover the -operation's full ordered list. Candidate capabilities describe possible -providers. Top-level session capabilities describe the current route, and +operation's full ordered list. Candidate capabilities, returned with complete +detail, describe possible providers. Top-level session capabilities describe the current route, and `analysis_provider_binding` identifies the selected provider. ## Ghidra startup deadline recovery diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 40e44baf8b..1b629e55ad 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -583,11 +583,22 @@ capabilities. The default result contains the complete inventory in groups that share status facts. `tool_availability.client_requirements` is the baseline for each group; a group's optional `client_requirements` replaces that baseline for the group's tools. Expand each group's `names` into one tool entry, carrying its -surface, availability, reason, remediation, default-mode status, modes, and -client requirements. Compare the union of group names with `tools/list` when -checking that a client received the full catalog. An operation absent from all -groups is missing from the complete inventory; it is not an implicit -unavailable entry. +availability, reason, remediation, default-mode status, modes, and client +requirements. Compare the union of group names with `tools/list` when checking +that a client received the full catalog. An operation absent from all groups is +missing from the complete inventory; it is not an implicit unavailable entry. + +`detail` selects the projection and the result echoes it. The default +`summary` keeps open state, target identity, the binding, current-route +`capabilities`, run, activity and health status, and each provider candidate's +identity, selection, and availability and target-support `status`, `code`, and +`reason`. It lists the fields it leaves out in `omitted_fields`: provider +`diagnostics`, candidate `capabilities`, and each tool group's static `surface`, +`effects`, and `annotations`. Omitted fields are not empty or false. +`detail: "complete"` returns them inline, groups tools by those flags too, and +has no `omitted_fields`. The output schema keys each result's candidate, +tool-group and omission fields by its `detail`. CLI `capabilities` and +`providers` always return complete provider facts. `get_call_graph` and `trace_call_path` report `forward` and `backward` modes. Each requires `procedure_address` plus `procedure_callees` or `procedure_callers`, @@ -600,8 +611,8 @@ finish without reading relationships, even when neither traversal mode is available. Discovery does not block that direct invocation or replace its typed partial/error outcomes. -The `effects` and `annotations` arrays list the true members of their fixed -vocabularies. A known member omitted from either array is false. This applies +With complete detail, the `effects` and `annotations` arrays list the true +members of their fixed vocabularies. A known member omitted from either array is false. This applies only to these closed flag sets; it does not make an omitted tool, provider, mode, or limitation false. Keep availability reasons, remediation, client-feature requirements, and mode facts inline with the grouped tool names. The optional @@ -632,7 +643,8 @@ identity, availability, reason, rejection code, effects, and ordered limitations. `binary_session.analysis_provider_candidates` is authoritative for deep-engine -discovery. Each candidate's capability sets describe that possible provider; +discovery. Each candidate's capability sets, returned with complete detail, +describe that possible provider; they do not select it. The top-level `capabilities` describe the current route, while `analysis_provider_binding` identifies the concrete selected route. These fields are not interchangeable. Target-free discovery is sorted by provider ID, diff --git a/scripts/lib/mcp-verifier-results.mjs b/scripts/lib/mcp-verifier-results.mjs index def0effbb3..6dcd44f1c0 100644 --- a/scripts/lib/mcp-verifier-results.mjs +++ b/scripts/lib/mcp-verifier-results.mjs @@ -45,13 +45,19 @@ const operationResultKey = (contract) => { return evidence ? "normalized_result" : "result"; }; -/** Expand compact session metadata once at the verifier boundary. */ +/** + * Expand compact session metadata once at the verifier boundary. The caller + * has validated the status against the binary_session contract, so a summary + * carries summary candidates without capability sets; complete detail must + * still expand every candidate's capabilities. + */ export const expandMcpSessionStatus = (status) => ({ ...status, capabilities: expandProviderCapabilities(status.capabilities), - analysis_provider_candidates: expandProviderCandidates( - status.analysis_provider_candidates, - ), + analysis_provider_candidates: + status.detail === "summary" + ? status.analysis_provider_candidates + : expandProviderCandidates(status.analysis_provider_candidates), tool_availability: expandToolAvailability(status.tool_availability), }); diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 618b2921db..2817af7dc7 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -9,6 +9,7 @@ import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { expandToolAvailability, toolAvailabilityInventory, + toolAvailabilitySummaryInventory, type ToolAvailability, } from "../contracts/toolOutputSchemaPrimitives.js"; @@ -885,3 +886,44 @@ it("keeps independent native sessions discoverable without an unrelated binary t { available: false, reason: "target_required" }, ); }); + +describe("summary tool availability", () => { + it("summarizes every tool's current availability without static catalog flags", () => { + const full = buildCapabilityInventory(status(), enabledPolicy); + const withOverride = full.map((entry, index) => + index === 1 + ? { + ...entry, + available: false as const, + reason: "client_capability_missing" as const, + remediation: "Use an MCP client with roots support.", + client_requirements: { + required: ["roots" as const], + optional: [], + missing_required: ["roots" as const], + missing_optional: [], + }, + } + : entry, + ); + const complete = compactToolAvailability(withOverride); + const summary = compactToolAvailability(withOverride, "summary"); + + expect(toolAvailabilitySummaryInventory.parse(summary)).toEqual(summary); + expect(toolAvailabilityInventory.safeParse(summary).success).toBe(false); + expect(summary.groups.length).toBeLessThan(complete.groups.length); + expect(JSON.stringify(summary).length).toBeLessThan( + JSON.stringify(complete).length / 2, + ); + for (const group of summary.groups) + for (const field of ["surface", "effects", "annotations"]) + expect(Object.hasOwn(group, field)).toBe(false); + const byName = (left: { name: string }, right: { name: string }) => + left.name.localeCompare(right.name); + expect(expandToolAvailability(summary).toSorted(byName)).toEqual( + withOverride + .map(({ surface: _s, effects: _e, annotations: _a, ...entry }) => entry) + .toSorted(byName), + ); + }); +}); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index d3eb6ad101..b7da4f10ff 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -6,6 +6,7 @@ import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadat import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; import { NATIVE_CALL_ROUTE_SOURCES } from "./native/NativeCallRoutes.js"; import { z } from "zod"; +import type { BinarySessionDetail } from "../contracts/sessionStatusContract.js"; import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; import type { OptionalProviderLoadFailures, @@ -16,11 +17,13 @@ import { expandProviderCapabilities, providerCapabilitySet, toolAvailabilityInventory, + toolAvailabilitySummaryInventory, TOOL_ANNOTATION_NAMES, TOOL_EFFECT_NAMES, type ClientFeatureAvailability, type ProviderCapability, type ToolAvailabilityInventory, + type ToolAvailabilitySummaryInventory, type ToolAvailability, type ToolAvailabilityMode, type ToolUnavailabilityReason, @@ -323,10 +326,6 @@ export const buildCapabilityInventory = ( }; type ClientRequirements = ToolAvailability["client_requirements"]; -type MutableAvailabilityGroup = Omit< - ToolAvailabilityInventory["groups"][number], - "names" -> & { names: string[] }; const EMPTY_CLIENT_REQUIREMENTS: ClientRequirements = { required: [], @@ -335,23 +334,29 @@ const EMPTY_CLIENT_REQUIREMENTS: ClientRequirements = { missing_optional: [], }; -/** Compact repeated metadata while retaining every operation's current availability inline. */ -export const compactToolAvailability = ( +/** + * Group every operation's current availability inline. Complete detail also + * groups by the static surface, effect, and annotation flags; summary omits + * those flags so tools that share a status collapse into one group. + */ +export function compactToolAvailability( availability: readonly ToolAvailability[], -): ToolAvailabilityInventory => { + detail?: "complete", +): ToolAvailabilityInventory; +export function compactToolAvailability( + availability: readonly ToolAvailability[], + detail: BinarySessionDetail, +): ToolAvailabilityInventory | ToolAvailabilitySummaryInventory; +export function compactToolAvailability( + availability: readonly ToolAvailability[], + detail: BinarySessionDetail = "complete", +): ToolAvailabilityInventory | ToolAvailabilitySummaryInventory { const clientRequirements = availability[0]?.client_requirements ?? EMPTY_CLIENT_REQUIREMENTS; - const groups = new Map(); + const groups = new Map(); for (const entry of availability) { - const effects = TOOL_EFFECT_NAMES.filter((name) => entry.effects[name]); - const annotations = TOOL_ANNOTATION_NAMES.filter( - (name) => entry.annotations[name], - ); - const groupFacts = { - surface: entry.surface, - effects, - annotations, + const statusFacts = { available: entry.available, reason: entry.reason, remediation: entry.remediation, @@ -364,17 +369,31 @@ export const compactToolAvailability = ( ? {} : { client_requirements: entry.client_requirements }), }; + const groupFacts = + detail === "summary" + ? statusFacts + : { + surface: entry.surface, + effects: TOOL_EFFECT_NAMES.filter((name) => entry.effects[name]), + annotations: TOOL_ANNOTATION_NAMES.filter( + (name) => entry.annotations[name], + ), + ...statusFacts, + }; const key = JSON.stringify(groupFacts); const existing = groups.get(key); if (existing) existing.names.push(entry.name); else groups.set(key, { ...groupFacts, names: [entry.name] }); } - return toolAvailabilityInventory.parse({ + const inventory = { client_requirements: clientRequirements, groups: [...groups.values()], - }); -}; + }; + return detail === "summary" + ? toolAvailabilitySummaryInventory.parse(inventory) + : toolAvailabilityInventory.parse(inventory); +} const availabilityFor = (context: AvailabilityContext): Availability => { // These built-in workflows accept a caller-selected endpoint; they do not load the optional passive provider. diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 52f27b883e..d0f80297d4 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -36,6 +36,7 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js"; import { BinarySessionRecords } from "./BinarySessionRecords.js"; import { binarySessionStatus } from "./BinarySessionStatus.js"; +import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js"; import { resolveSessionOpen, resolveSessionTarget, @@ -1024,7 +1025,12 @@ export class BinarySession } /** Describe the active binary session. */ - status(options: { readonly signal?: AbortSignal } = {}): Promise { + status( + options: { + readonly signal?: AbortSignal; + readonly detail?: BinarySessionDetail; + } = {}, + ): Promise { const resourceRun = this.#activeBundleResources; const run = this.#active ?? diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index 7a3f87fd9a..73b95aa8b4 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -8,6 +8,7 @@ import type { JsonValue } from "../../domain/jsonValue.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js"; +import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Result } from "../../domain/result.js"; import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; @@ -93,7 +94,11 @@ export interface BinarySessionPort overwrite: boolean, options?: Pick, ): Promise>; - status(options?: { readonly signal?: AbortSignal }): Promise; + /** Default complete detail; summary omits provider diagnostics and candidate capabilities. */ + status(options?: { + readonly signal?: AbortSignal; + readonly detail?: BinarySessionDetail; + }): Promise; activeTarget(): BinaryTarget | undefined; /** Read a snapshot with failed descriptors retained for session cleanup retry. */ readAnalysisSnapshot( diff --git a/src/application/binary/BinarySessionStatus.test.ts b/src/application/binary/BinarySessionStatus.test.ts index 12cac2defb..15e2303352 100644 --- a/src/application/binary/BinarySessionStatus.test.ts +++ b/src/application/binary/BinarySessionStatus.test.ts @@ -5,9 +5,12 @@ import type { CapabilityDescriptor, ProviderIdentity, } from "../AnalysisProvider.js"; +import { z } from "zod"; import { + analysisProviderCandidate, + analysisProviderCandidateSummary, expandProviderCapabilities, - sessionProvider, + sessionProviderFacts, type ProviderCapabilitySet, } from "../../contracts/toolOutputSchemaPrimitives.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; @@ -23,9 +26,13 @@ import { nativeMacOSCapabilities, } from "../../native/NativeMacOSProviderMetadata.js"; -const projectedStatusSchema = sessionProvider.pick({ - capabilities: true, - analysis_provider_candidates: true, +const projectedStatusSchema = z.object({ + capabilities: sessionProviderFacts.shape.capabilities, + analysis_provider_candidates: z.array(analysisProviderCandidate), +}); +const summaryStatusSchema = z.object({ + capabilities: sessionProviderFacts.shape.capabilities, + analysis_provider_candidates: z.array(analysisProviderCandidateSummary), }); const providerA: ProviderIdentity = { @@ -126,6 +133,25 @@ const project = async ( { readonly reason: string } > = new Map(), target?: BinaryTarget, +) => + projectedStatusSchema.parse( + await projectStatus(routeCapabilities, candidates, { + runtimeUnavailability, + ...(target === undefined ? {} : { target }), + }), + ); + +const projectStatus = async ( + routeCapabilities: readonly CapabilityDescriptor[], + candidates: readonly AnalysisProviderCandidateStatus[], + options: { + readonly runtimeUnavailability?: ReadonlyMap< + string, + { readonly reason: string } + >; + readonly target?: BinaryTarget; + readonly detail?: "summary" | "complete"; + } = {}, ) => { const route: SessionProviderRoute = { identity: providerA, @@ -142,17 +168,16 @@ const project = async ( const router: Pick = { candidateStatuses: async () => candidates, }; - return projectedStatusSchema.parse( - await binarySessionStatus({ - target, - route, - router, - runtimeUnavailability, - runId: undefined, - runtimeLineageSnapshots: [], - requestActivitySnapshots: [], - }), - ); + return binarySessionStatus({ + target: options.target, + route, + router, + runtimeUnavailability: options.runtimeUnavailability ?? new Map(), + runId: undefined, + runtimeLineageSnapshots: [], + requestActivitySnapshots: [], + ...(options.detail === undefined ? {} : { detail: options.detail }), + }); }; const expandCanonicalCapabilities = (sets: readonly ProviderCapabilitySet[]) => @@ -444,3 +469,87 @@ it.each(["elf", "pe"] as const)( ]); }, ); + +describe("binary session summary projection", () => { + it("keeps each candidate's status, code and reason in summary without diagnostics or capability sets", async () => { + const unavailable: AnalysisProviderCandidateStatus = { + ...candidate(providerB, otherCapabilities), + availability: { + status: "unavailable", + code: "not_configured", + reason: "Set PROVIDER_B_HOME; see docs/provider-b.md.", + diagnostics: { checks: [{ name: "configuration", ok: false }] }, + }, + targetSupport: { + status: "unsupported", + code: "architecture_unsupported", + reason: "Provider B cannot select this slice.", + diagnostics: { architecture: "arm64" }, + }, + selected: false, + }; + const pending: AnalysisProviderCandidateStatus = { + ...candidate(providerA, selectedCapabilities), + availability: { + status: "unknown", + code: null, + reason: "Provider A startup is still pending.", + diagnostics: { startup: "pending" }, + }, + }; + const candidates = [pending, unavailable]; + const before = structuredClone(candidates); + const runtimeUnavailability = new Map([ + ["procedure_info", { reason: "Runtime operation failure" }], + ]); + const complete = projectedStatusSchema.parse( + await projectStatus(selectedCapabilities, candidates, { + runtimeUnavailability, + }), + ); + const raw = await projectStatus(selectedCapabilities, candidates, { + runtimeUnavailability, + detail: "summary", + }); + const summary = summaryStatusSchema.parse(raw); + + expect(candidates).toEqual(before); + expect(summary.capabilities).toEqual(complete.capabilities); + expect(summary.analysis_provider_candidates).toEqual([ + { + provider: providerA, + availability: { + status: "unknown", + code: null, + reason: "Provider A startup is still pending.", + }, + target_support: { + status: "unknown", + code: null, + reason: "No target is open.", + }, + selected: true, + }, + { + provider: providerB, + availability: { + status: "unavailable", + code: "not_configured", + reason: "Set PROVIDER_B_HOME; see docs/provider-b.md.", + }, + target_support: { + status: "unsupported", + code: "architecture_unsupported", + reason: "Provider B cannot select this slice.", + }, + selected: false, + }, + ]); + expect(JSON.stringify(raw)).not.toContain("diagnostics"); + expect( + analysisProviderCandidate + .array() + .safeParse(summary.analysis_provider_candidates).success, + ).toBe(false); + }); +}); diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 7cc45e1f18..c7d1335354 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -19,6 +19,7 @@ import type { SessionProviderRouter, } from "./SessionProviderRouter.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; +import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js"; interface BinarySessionStatusInput { readonly target: BinaryTarget | undefined; @@ -35,6 +36,7 @@ interface BinarySessionStatusInput { readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[]; readonly providerOperationHealth?: ProviderOperationHealth | null; readonly cleanupPending?: boolean; + readonly detail?: BinarySessionDetail; readonly signal?: AbortSignal; } @@ -51,6 +53,7 @@ export const binarySessionStatus = async ({ requestActivitySnapshots, providerOperationHealth, cleanupPending = false, + detail = "complete", signal, }: BinarySessionStatusInput): Promise => { const routeCapabilities = ( @@ -97,20 +100,31 @@ export const binarySessionStatus = async ({ const analysisProviderCandidates = bundleResourceRoot !== undefined ? [] - : (await router.candidateStatuses(route, signal)).map((candidate) => ({ - provider: providerSummary(candidate.provider), - availability: structuredClone(candidate.availability), - target_support: structuredClone(candidate.targetSupport), - selected: candidate.selected, - capabilities: providerCapabilitySets( - candidate.capabilities - .slice() - .filter(({ operation }) => operation !== "inventory_artifact") - .sort((left, right) => - compareUnicodeCodePoints(left.operation, right.operation), - ), - ), - })); + : (await router.candidateStatuses(route, signal)).map((candidate) => + detail === "summary" + ? { + provider: providerSummary(candidate.provider), + availability: providerStateSummary(candidate.availability), + target_support: providerStateSummary(candidate.targetSupport), + selected: candidate.selected, + } + : { + provider: providerSummary(candidate.provider), + availability: structuredClone(candidate.availability), + target_support: structuredClone(candidate.targetSupport), + selected: candidate.selected, + capabilities: providerCapabilitySets( + candidate.capabilities + .slice() + .filter( + ({ operation }) => operation !== "inventory_artifact", + ) + .sort((left, right) => + compareUnicodeCodePoints(left.operation, right.operation), + ), + ), + }, + ); const common = { cleanup_pending: cleanupPending, capabilities, @@ -185,6 +199,19 @@ const requestActivityStatus = ( }; }; +/** Keep a provider-owned status, code, and reason; diagnostics stay in complete detail. */ +const providerStateSummary = < + State extends { + readonly status: string; + readonly code: string | null; + readonly reason: string | null; + }, +>({ + status, + code, + reason, +}: State) => ({ status, code, reason }); + const providerSummary = ({ id, name, diff --git a/src/contracts/sessionStatusContract.ts b/src/contracts/sessionStatusContract.ts index 1c4d321338..e7c9bcbe44 100644 --- a/src/contracts/sessionStatusContract.ts +++ b/src/contracts/sessionStatusContract.ts @@ -1,8 +1,31 @@ import { z } from "zod"; import { absoluteLocalPathStringSchema } from "../domain/localPath.js"; +/** Caller-selected binary_session projections. */ +export const BINARY_SESSION_DETAILS = ["summary", "complete"] as const; +export type BinarySessionDetail = (typeof BINARY_SESSION_DETAILS)[number]; + +/** + * Fields a summary leaves out, as dotted paths through arrays. Each stays + * available from the same session with detail: "complete". + */ +export const BINARY_SESSION_SUMMARY_OMITTED_FIELDS = [ + "analysis_provider_candidates.availability.diagnostics", + "analysis_provider_candidates.target_support.diagnostics", + "analysis_provider_candidates.capabilities", + "tool_availability.groups.surface", + "tool_availability.groups.effects", + "tool_availability.groups.annotations", +] as const; + /** Optional expectations used to compare the live session with its caller. */ export const binarySessionInputSchema = z.strictObject({ + detail: z + .enum(BINARY_SESSION_DETAILS) + .default("summary") + .describe( + "summary (default) keeps open state, the binding, current-route capabilities, each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. complete adds provider diagnostics, candidate capabilities and static tool surface, effect and annotation flags.", + ), expected_package_version: z.string().min(1).optional(), expected_catalog_digest: z .string() diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 87ac16b2c7..e5884f8643 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -80,7 +80,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "binary_session", - "Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis.", + 'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. The default summary lists omitted_fields; detail: "complete" adds provider diagnostics, candidate capabilities and static tool flags.', binarySessionInputSchema, ), session( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index cd12ee3bf2..d062fa4ffc 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -14,7 +14,15 @@ import { import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; import { analysisDetailOutputSchemaOf } from "./analysisDetail.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { + functionDossierSchema, + analysisBookmarkSchema, + functionInstructionWindowSchema, + referenceEdgeSchema, + unresolvedCallSchema, + procedureIdentitySchema, + containingProcedureResolutionSchema, +} from "../domain/hopperValues.js"; import { nativeInventoryRowsSchemas } from "../domain/native/nativeInventory.js"; import { z } from "zod"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; @@ -43,14 +51,6 @@ import { residualUnknownSchema } from "../domain/residualUnknown.js"; import { evidenceBundleSchema } from "../domain/evidenceBundle.js"; import { processCaptureComparisonSchema } from "../domain/process/processComparison.js"; -import { - analysisBookmarkSchema, - functionInstructionWindowSchema, - referenceEdgeSchema, - unresolvedCallSchema, - procedureIdentitySchema, - containingProcedureResolutionSchema, -} from "../domain/hopperValues.js"; import { nativeApiInspectionResultSchema } from "../domain/native/nativeApiBoundary.js"; import { demangleSwiftSchema, @@ -97,7 +97,8 @@ import { objcClassDiscoveryOutput, evidenceResultOf, segmentOutput, - sessionProvider, + sessionProviderFacts, + sessionStatusDetailShapes, symbolDiscoveryOutput, targetFormatSchema, targetKindSchema, @@ -564,18 +565,27 @@ export const sessionOutputSchemas = { }), ]), ), + // Every session kind crossed with every detail level, so `detail` fixes + // which candidate, tool-group and omission fields the result carries. binary_session: lifecycleResultOf( - z.union([ - sessionProvider.extend({ open: z.literal(false) }), - sessionProvider.extend({ - open: z.literal(true), - session_kind: z.literal("bundle_resources"), - resource_root: bundleResourceRootSchema, - }), - ...openLifecycleTargetVariants.map((variant) => - sessionProvider.extend({ open: z.literal(true), ...variant.shape }), - ), - ]), + z.union( + sessionStatusDetailShapes.flatMap((detail) => [ + sessionProviderFacts.extend({ open: z.literal(false), ...detail }), + sessionProviderFacts.extend({ + open: z.literal(true), + session_kind: z.literal("bundle_resources"), + resource_root: bundleResourceRootSchema, + ...detail, + }), + ...openLifecycleTargetVariants.map((variant) => + sessionProviderFacts.extend({ + open: z.literal(true), + ...variant.shape, + ...detail, + }), + ), + ]), + ), ), export_evidence_bundle: lifecycleResultOf( z.object({ diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 51e122c22f..1c4caf5670 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -21,6 +21,7 @@ import { analysisErrorProjectionSchema } from "./errorSchemas.js"; import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js"; import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js"; import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js"; +import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js"; /** Specialize the single normalized result inside a complete Evidence record. */ export const evidenceResultOf = (schema: Schema) => @@ -256,41 +257,48 @@ const providerRejectionCode: z.ZodType = z.enum( const providerDiagnostics = z.record(z.string(), jsonValueSchema); -const providerAvailability = z.discriminatedUnion("status", [ - z.object({ +const providerAvailabilityStates = { + unknown: z.object({ status: z.literal("unknown"), code: z.null(), reason: z.string().min(1), - diagnostics: providerDiagnostics, }), - z.object({ + available: z.object({ status: z.literal("available"), code: z.null(), reason: z.null(), - diagnostics: providerDiagnostics, }), - z.object({ + unavailable: z.object({ status: z.literal("unavailable"), code: providerRejectionCode, reason: z.string().min(1), + }), +}; + +const providerAvailability = z.discriminatedUnion("status", [ + providerAvailabilityStates.unknown.extend({ + diagnostics: providerDiagnostics, + }), + providerAvailabilityStates.available.extend({ + diagnostics: providerDiagnostics, + }), + providerAvailabilityStates.unavailable.extend({ diagnostics: providerDiagnostics, }), ]); -const providerTargetSupport = z.discriminatedUnion("status", [ - z.object({ +const providerTargetSupportStates = { + unknown: z.object({ status: z.literal("unknown"), code: z.null(), reason: z.string().min(1), - diagnostics: providerDiagnostics, }), - z.object({ + supported: z.object({ status: z.literal("supported"), code: z.null(), reason: z.null(), - diagnostics: providerDiagnostics, }), - z.object({ + unsupported: z.object({ status: z.literal("unsupported"), code: z.enum([ "target_kind_unsupported", @@ -300,10 +308,46 @@ const providerTargetSupport = z.discriminatedUnion("status", [ "managed_target_unsupported", ]), reason: z.string().min(1), + }), +}; + +const providerTargetSupport = z.discriminatedUnion("status", [ + providerTargetSupportStates.unknown.extend({ + diagnostics: providerDiagnostics, + }), + providerTargetSupportStates.supported.extend({ + diagnostics: providerDiagnostics, + }), + providerTargetSupportStates.unsupported.extend({ diagnostics: providerDiagnostics, }), ]); +/** Complete candidate facts, including provider-owned diagnostics and capability sets. */ +export const analysisProviderCandidate = z.object({ + provider: providerIdentity, + availability: providerAvailability, + target_support: providerTargetSupport, + selected: z.boolean(), + capabilities: z.array(providerCapabilitySet), +}); + +/** Summary candidate: the same status, code, and reason without diagnostics or capabilities. */ +export const analysisProviderCandidateSummary = z.strictObject({ + provider: providerIdentity, + availability: z.discriminatedUnion("status", [ + providerAvailabilityStates.unknown.strict(), + providerAvailabilityStates.available.strict(), + providerAvailabilityStates.unavailable.strict(), + ]), + target_support: z.discriminatedUnion("status", [ + providerTargetSupportStates.unknown.strict(), + providerTargetSupportStates.supported.strict(), + providerTargetSupportStates.unsupported.strict(), + ]), + selected: z.boolean(), +}); + const clientFeatureNameSchema = z.enum([ "elicitation_form", "elicitation_url", @@ -404,45 +448,93 @@ export const TOOL_ANNOTATION_NAMES = Object.keys( toolAvailabilityFacts.annotations.shape, ) as (keyof z.output)[]; -const toolAvailabilityGroupFacts = { +const toolAvailabilityStatusGroupFacts = { names: z.array(z.string().min(1)).min(1), - surface: toolAvailabilityFacts.surface, default_mode_available: toolAvailabilityFacts.default_mode_available, modes: toolAvailabilityFacts.modes, client_requirements: toolAvailabilityFacts.client_requirements.optional(), +}; + +const toolAvailabilityGroupFacts = { + names: toolAvailabilityStatusGroupFacts.names, + surface: toolAvailabilityFacts.surface, + default_mode_available: + toolAvailabilityStatusGroupFacts.default_mode_available, + modes: toolAvailabilityStatusGroupFacts.modes, + client_requirements: toolAvailabilityStatusGroupFacts.client_requirements, effects: z.array(toolAvailabilityFacts.effects.keyof()), annotations: z.array(toolAvailabilityFacts.annotations.keyof()), }; -const toolAvailabilityGroup = z.discriminatedUnion("available", [ - z.strictObject({ - ...toolAvailabilityGroupFacts, - available: z.literal(true), - reason: z.literal("available"), - remediation: z.null(), - }), - z.strictObject({ - ...toolAvailabilityGroupFacts, - available: z.literal(false), - reason: toolUnavailabilityReason, - remediation: z.string().nullable(), - }), -]); +const toolAvailabilityGroupOf = (facts: Facts) => + z.discriminatedUnion("available", [ + z.strictObject({ + ...facts, + available: z.literal(true), + reason: z.literal("available"), + remediation: z.null(), + }), + z.strictObject({ + ...facts, + available: z.literal(false), + reason: toolUnavailabilityReason, + remediation: z.string().nullable(), + }), + ]); /** Complete inventory with explicit inherited client requirements and enabled flags. */ export const toolAvailabilityInventory = z.strictObject({ client_requirements: toolAvailabilityFacts.client_requirements, - groups: z.array(toolAvailabilityGroup), + groups: z.array(toolAvailabilityGroupOf(toolAvailabilityGroupFacts)), }); export type ToolAvailabilityInventory = z.output< typeof toolAvailabilityInventory >; +/** + * Summary inventory: every tool name with its current availability, reason, + * remediation, modes, and client requirements, grouped by those facts alone. + * Static surface, effect, and annotation flags are omitted, not false. + */ +export const toolAvailabilitySummaryInventory = z.strictObject({ + client_requirements: toolAvailabilityFacts.client_requirements, + groups: z.array(toolAvailabilityGroupOf(toolAvailabilityStatusGroupFacts)), +}); +export type ToolAvailabilitySummaryInventory = z.output< + typeof toolAvailabilitySummaryInventory +>; + +/** One tool's current availability without its static catalog flags. */ +export type ToolAvailabilitySummary = Omit< + ToolAvailability, + "surface" | "effects" | "annotations" +>; + +const isCompleteToolAvailabilityInventory = ( + inventory: ToolAvailabilityInventory | ToolAvailabilitySummaryInventory, +): inventory is ToolAvailabilityInventory => + inventory.groups.every((group) => "surface" in group); + /** Expand the wire representation once at the consuming boundary. */ -export const expandToolAvailability = ( +export function expandToolAvailability( inventory: ToolAvailabilityInventory, -): readonly ToolAvailability[] => - inventory.groups.flatMap((group) => +): readonly ToolAvailability[]; +export function expandToolAvailability( + inventory: ToolAvailabilityInventory | ToolAvailabilitySummaryInventory, +): readonly (ToolAvailability | ToolAvailabilitySummary)[]; +export function expandToolAvailability( + inventory: ToolAvailabilityInventory | ToolAvailabilitySummaryInventory, +): readonly (ToolAvailability | ToolAvailabilitySummary)[] { + if (!isCompleteToolAvailabilityInventory(inventory)) + return inventory.groups.flatMap(({ names, ...group }) => + names.map((name) => ({ + ...group, + name, + client_requirements: + group.client_requirements ?? inventory.client_requirements, + })), + ); + return inventory.groups.flatMap((group) => group.names.map((name) => toolAvailability.parse({ ...group, @@ -464,8 +556,10 @@ export const expandToolAvailability = ( }), ), ); +} -export const sessionProvider = z +/** Session status facts shared by every detail level and session kind. */ +export const sessionProviderFacts = z .object({ cleanup_pending: z.boolean(), capabilities: z.array(providerCapabilitySet), @@ -483,16 +577,6 @@ export const sessionProvider = z analysis_profile: analysisProfileSchema, }) .nullable(), - analysis_provider_candidates: z.array( - z.object({ - provider: providerIdentity, - availability: providerAvailability, - target_support: providerTargetSupport, - selected: z.boolean(), - capabilities: z.array(providerCapabilitySet), - }), - ), - tool_availability: toolAvailabilityInventory, client_features: z.object({ elicitation_form: z.boolean(), elicitation_url: z.boolean(), @@ -546,6 +630,34 @@ export const sessionProvider = z }) .passthrough(); +/** + * Fields whose shape `detail` selects. A summary must list what it omits and + * carry only summary candidates and tool groups; complete detail must carry + * diagnostics, candidate capabilities and static tool flags, and no omission list. + */ +export const sessionStatusDetailShapes = [ + { + detail: z.literal("summary"), + omitted_fields: z + .array(z.enum(BINARY_SESSION_SUMMARY_OMITTED_FIELDS)) + .min(1), + analysis_provider_candidates: z.array(analysisProviderCandidateSummary), + tool_availability: toolAvailabilitySummaryInventory, + }, + { + detail: z.literal("complete"), + omitted_fields: z.never().optional(), + analysis_provider_candidates: z.array(analysisProviderCandidate), + tool_availability: toolAvailabilityInventory, + }, +] as const; + +/** Session status for either detail level, keyed by `detail`. */ +export const sessionProvider = z.discriminatedUnion("detail", [ + sessionProviderFacts.extend(sessionStatusDetailShapes[0]), + sessionProviderFacts.extend(sessionStatusDetailShapes[1]), +]); + export const nullableText = z.string().nullable(); export const addressList = z.array(z.string()); export const addressedEntry = z.object({ diff --git a/src/process/RegexMatcher.test.ts b/src/process/RegexMatcher.test.ts index 7b75a9ba94..60552860ed 100644 --- a/src/process/RegexMatcher.test.ts +++ b/src/process/RegexMatcher.test.ts @@ -13,6 +13,7 @@ import { execArgvFromCommandLine, readLaunchExecArgv, readLaunchNodeOptions, + regexMatchingDeadlineError, regexMatchingLimitation, regexWorkerHeapError, resolveLaunchRegexWorkerHeap, @@ -458,12 +459,32 @@ it("describes the effective heap and the configurable matching deadline", () => { kind: "unknown" } as const, ]) { const limitation = regexMatchingLimitation(heap); - expect(limitation).toContain( + for (const fact of [ + "5000 ms", + "128 MiB", + "source default", + "regex.matching_deadline_ms", + "regex.worker_heap_request_mib", + "REA_REGEX_MATCHING_DEADLINE_MS", + "REA_REGEX_WORKER_HEAP_REQUEST_MIB", + "update_analysis_settings", + ]) + expect(limitation).toContain(fact); + // Providers repeat this text per search operation; bounds and recovery + // stay with the errors below instead of every capability entry. + expect(limitation).not.toContain( regexMatchingDeadlineRecovery(DEFAULT_REGEX_MATCHING_DEADLINE), ); - expect(limitation).toContain( + expect(limitation).not.toContain( regexWorkerHeapRequestRecovery(DEFAULT_REGEX_WORKER_HEAP_REQUEST), ); + expect(limitation.length).toBeLessThan(700); + if (heap.kind === "effective") + expect(limitation).toContain(`${String(heap.mib)} MiB`); + expect( + projectAnalysisError(regexMatchingDeadlineError("search_procedures")) + .remediation.action, + ).toContain(regexMatchingDeadlineRecovery(DEFAULT_REGEX_MATCHING_DEADLINE)); const projected = projectAnalysisError( regexWorkerHeapError("search_procedures", new Error("heap"), heap), ); @@ -478,10 +499,16 @@ it("describes the effective heap and the configurable matching deadline", () => expect(projected.remediation.action).toContain( "--max-old-space-size-percentage", ); + expect(projected.remediation.action).toContain( + regexWorkerHeapRequestRecovery(DEFAULT_REGEX_WORKER_HEAP_REQUEST), + ); if (heap.kind === "unknown") { - expect(limitation).toContain("--max-old-space-size-percentage"); - expect(limitation).toContain("--max-heap-size"); - expect(limitation).toContain("--experimental-config-file"); + expect(projected.remediation.action).toContain( + "--max-old-space-size-percentage", + ); + expect(projected.remediation.action).toContain( + "--experimental-config-file", + ); expect(projected.remediation.action).toContain("--max-heap-size"); expect(projected.remediation.action).toContain( "--experimental-default-config-file", @@ -515,13 +542,21 @@ it("keeps a selected heap request unless a positive Node flag overrides it", () DEFAULT_REGEX_MATCHING_DEADLINE, request, ); - expect(selected).toContain(regexWorkerHeapRequestRecovery(request)); + for (const fact of ["256 MiB", "source setting"]) + expect(selected).toContain(fact); + expect(selected).not.toContain("--max-old-space-size"); const overridden = regexMatchingLimitation( effective(64, true), DEFAULT_REGEX_MATCHING_DEADLINE, request, ); - expect(overridden).toContain(regexWorkerHeapRequestRecovery(request)); + for (const fact of [ + "64 MiB", + "256 MiB", + "source setting", + "--max-old-space-size", + ]) + expect(overridden).toContain(fact); const projected = projectAnalysisError( regexWorkerHeapError( "search_procedures", diff --git a/src/process/RegexMatcher.ts b/src/process/RegexMatcher.ts index 78862c283e..43d3778ebf 100644 --- a/src/process/RegexMatcher.ts +++ b/src/process/RegexMatcher.ts @@ -14,6 +14,7 @@ import { import { err, ok, type Result } from "../domain/result.js"; import { DEFAULT_REGEX_MATCHING_DEADLINE, + REGEX_MATCHING_DEADLINE_SETTING, regexMatchingDeadlineRecovery, type RegexMatchingDeadline, } from "../config/regexMatchingDeadline.js"; @@ -413,18 +414,25 @@ const heapBound = ( : `${override}. This process has no positive override, so the effective heap is ${String(heap.mib)} MiB. ${recovery}`; }; -/** Capability text for the supervised ECMAScript matching boundary. */ +/** + * Capability text for the supervised ECMAScript matching boundary: the + * effective budgets, their sources, and where to change them. Providers repeat + * it per search operation, so setting bounds and step-by-step recovery stay + * with the deadline and heap errors instead. + */ export const regexMatchingLimitation = ( heap: RegexWorkerHeapLimit, deadline: RegexMatchingDeadline = DEFAULT_REGEX_MATCHING_DEADLINE, request: RegexWorkerHeapRequest = DEFAULT_REGEX_WORKER_HEAP_REQUEST, ): string => { - const deadlineText = `${String(deadline.deadlineMs)} ms matching deadline (source ${deadline.source})`; - const preface = - heap.kind === "effective" - ? `Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a ${deadlineText} and an effective ${String(heap.mib)} MiB old-generation heap.` - : `Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a ${deadlineText}.`; - return `${preface} ${regexMatchingDeadlineRecovery(deadline)} ${heapBound(heap, request)} Matching failure leaves the service available.`; + const requested = `${String(request.heapMiB)} MiB worker heap request (source ${request.source})`; + const heapText = + heap.kind === "unknown" + ? `a ${requested}; launch heap flags can change the effective old-generation heap, which was not established` + : heap.overridden + ? `an effective ${String(heap.mib)} MiB old-generation heap set by a positive Node --max-old-space-size over its ${requested}` + : `an effective ${String(heap.mib)} MiB old-generation heap from its ${requested}`; + return `Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a ${String(deadline.deadlineMs)} ms matching deadline (source ${deadline.source}) and ${heapText}. inspect_analysis_settings and update_analysis_settings read and change regex.matching_deadline_ms and regex.worker_heap_request_mib; ${REGEX_MATCHING_DEADLINE_SETTING} and ${REGEX_WORKER_HEAP_REQUEST_SETTING} seed them at launch. Deadline and heap errors report the bounds and recovery. Matching failure leaves the service available.`; }; /** Capability text for this process's launch heap, deadline, and heap request. */ diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index a958a41d81..bcac51502e 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -156,7 +156,10 @@ const providerCandidates = async ( if (session === undefined) return []; const signal = currentMcpRequestSignal(); const parsed = providerStatusSchema.safeParse( - await session.status(signal === undefined ? {} : { signal }), + await session.status({ + detail: "summary", + ...(signal === undefined ? {} : { signal }), + }), ); if (!parsed.success) return []; const candidates = parsed.data.analysis_provider_candidates.map( diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index f13d13bc56..e23b88c726 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -9,6 +9,10 @@ import { compactToolAvailability, } from "../application/CapabilityInventory.js"; import { toolContract } from "../contracts/toolContracts.js"; +import { + BINARY_SESSION_SUMMARY_OMITTED_FIELDS, + type BinarySessionDetail, +} from "../contracts/sessionStatusContract.js"; import type { ClientFeatureAvailability, ToolAvailability, @@ -95,7 +99,10 @@ export const registerSessionStatusTool = ( options, context.mcpReq.signal, ); - status = await session.status({ signal: context.mcpReq.signal }); + status = await session.status({ + signal: context.mcpReq.signal, + detail: input.detail, + }); } catch (cause) { if (!(cause instanceof AnalysisError)) throw cause; return server.delivery.toCallToolResult(err(cause), contract); @@ -143,6 +150,7 @@ export const registerSessionStatusTool = ( { ok: true, value: projectSessionStatus({ + detail: input.detail, status: statusObject, toolAvailability, serverIdentity, @@ -156,6 +164,7 @@ export const registerSessionStatusTool = ( }; const projectSessionStatus = (options: { + readonly detail: BinarySessionDetail; readonly status: Readonly< Record >; @@ -165,7 +174,14 @@ const projectSessionStatus = (options: { }) => jsonObjectSchema.parse({ ...options.status, - tool_availability: compactToolAvailability(options.toolAvailability), + detail: options.detail, + ...(options.detail === "summary" + ? { omitted_fields: [...BINARY_SESSION_SUMMARY_OMITTED_FIELDS] } + : {}), + tool_availability: compactToolAvailability( + options.toolAvailability, + options.detail, + ), client_features: options.clientFeatures, server_identity: options.serverIdentity, }); diff --git a/tests/acceptance/applications/runtime.test.ts b/tests/acceptance/applications/runtime.test.ts index e755d64d07..1e1ee8abe1 100644 --- a/tests/acceptance/applications/runtime.test.ts +++ b/tests/acceptance/applications/runtime.test.ts @@ -10,7 +10,7 @@ import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; import { evidenceResultOf, expandToolAvailability, - toolAvailabilityInventory, + toolAvailabilitySummaryInventory, } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -39,7 +39,7 @@ const expectAvailableToolInventory = async ( const inventory = z .object({ result: z.object({ - tool_availability: toolAvailabilityInventory, + tool_availability: toolAvailabilitySummaryInventory, }), }) .parse(status.structuredContent).result.tool_availability; diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index b3b1c16a05..7662d78abb 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -16,7 +16,7 @@ import { expandProviderCapabilities, expandToolAvailability, providerCapabilitySet, - toolAvailabilityInventory, + toolAvailabilitySummaryInventory, } from "../../src/contracts/toolOutputSchemaPrimitives.js"; import { @@ -369,7 +369,9 @@ cliTest( expect(response.isError).not.toBe(true); const sessionStatus = z .object({ - result: z.object({ tool_availability: toolAvailabilityInventory }), + result: z.object({ + tool_availability: toolAvailabilitySummaryInventory, + }), }) .parse(response.structuredContent); expect( diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index c8e019d8e7..cf62073f0a 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -248,7 +248,7 @@ it("advertises owned JavaScript analysis effects without foreign target executio const status = await client.callTool({ name: "binary_session", - arguments: {}, + arguments: { detail: "complete" }, }); expect(status.isError).not.toBe(true); const { result } = z diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index b1580b139d..4aa974854b 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -21,7 +21,10 @@ import type { } from "../../../src/application/frida/FridaInstrumentationPort.js"; import type { EvidenceWriter } from "../../../src/application/investigation/InvestigationRecordPort.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; -import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { + expandToolAvailability, + toolAvailabilityInventory, +} from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import type { AnalysisPartialObservation } from "../../../src/domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; @@ -303,12 +306,14 @@ it("retains detached state and resume evidence when remote cleanup needs retry", ).toBe(true); const inventory = await fixture.client.callTool({ name: "binary_session", - arguments: {}, + arguments: { detail: "complete" }, }); const availability = expandToolAvailability( - toolContract("binary_session").outputSchema.parse( - inventory.structuredContent, - ).result.tool_availability, + toolAvailabilityInventory.parse( + toolContract("binary_session").outputSchema.parse( + inventory.structuredContent, + ).result.tool_availability, + ), ); expect( availability.find((tool) => tool.name === "close_frida_session"), @@ -617,13 +622,15 @@ it("declares non-destructive session mutation when status retains Evidence", asy ).toMatchObject({ readOnlyHint: false, destructiveHint: false }); const inventory = await client.callTool({ name: "binary_session", - arguments: {}, + arguments: { detail: "complete" }, }); expect(inventory.isError).not.toBe(true); const available = expandToolAvailability( - toolContract("binary_session").outputSchema.parse( - inventory.structuredContent, - ).result.tool_availability, + toolAvailabilityInventory.parse( + toolContract("binary_session").outputSchema.parse( + inventory.structuredContent, + ).result.tool_availability, + ), ); expect( available.find((tool) => tool.name === "frida_session_status"), diff --git a/tests/boundary/mcp/jebMcp.test.ts b/tests/boundary/mcp/jebMcp.test.ts index c0b776f5d9..e845dd97e3 100644 --- a/tests/boundary/mcp/jebMcp.test.ts +++ b/tests/boundary/mcp/jebMcp.test.ts @@ -12,7 +12,10 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; -import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { + expandToolAvailability, + toolAvailabilityInventory, +} from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { JebProvider } from "../../../src/jeb/JebProvider.js"; import { @@ -60,14 +63,14 @@ it("discovers current JEB operation support and recovery through binary_session" const read = async () => { const result = await client.callTool({ name: "binary_session", - arguments: {}, + arguments: { detail: "complete" }, }); const status = toolContract("binary_session").outputSchema.parse( result.structuredContent, ).result; - return expandToolAvailability(status.tool_availability).filter( - (tool) => tool.surface === "jeb-provider", - ); + return expandToolAvailability( + toolAvailabilityInventory.parse(status.tool_availability), + ).filter((tool) => tool.surface === "jeb-provider"); }; const unavailable = await read(); expect(unavailable).toHaveLength(4); diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index 66d49eec6b..5c617fac5a 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -3,6 +3,7 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; @@ -25,6 +26,18 @@ import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.j import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; +import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "../../../src/contracts/sessionStatusContract.js"; +import { + toolContract, + TOOL_CONTRACTS, +} from "../../../src/contracts/toolContracts.js"; +import { + analysisProviderCandidate, + analysisProviderCandidateSummary, + expandToolAvailability, + toolAvailabilityInventory, + toolAvailabilitySummaryInventory, +} from "../../../src/contracts/toolOutputSchemaPrimitives.js"; const resources: Array<{ close(): Promise }> = []; let directory: string | undefined; @@ -235,6 +248,252 @@ it("offers admitted providers and retains unconfigured rejections", async () => expect(starts).toEqual([]); }); +const sessionStatusOf = async ( + mcp: Client, + arguments_: Record, +) => { + const reply = await mcp.callTool({ + name: "binary_session", + arguments: arguments_, + }); + expect(reply.isError).not.toBe(true); + const parsed = toolContract("binary_session").outputSchema.parse( + reply.structuredContent, + ).result; + const text = reply.content?.[0]; + if (text?.type !== "text") throw new Error("binary_session omitted text"); + expect(JSON.parse(text.text)).toEqual(reply.structuredContent); + return { + parsed, + raw: z + .object({ result: z.record(z.string(), z.unknown()) }) + .parse(reply.structuredContent), + bytes: Buffer.byteLength(text.text), + }; +}; + +type SessionStatus = Awaited>; + +/** Per-tool decision facts that both projections must carry. */ +const toolStatus = (inventory: Parameters[0]) => + expandToolAvailability(inventory) + .map( + ({ + name, + available, + reason, + remediation, + modes, + client_requirements, + }) => ({ + name, + available, + reason, + remediation, + modes, + client_requirements, + }), + ) + .toSorted((left, right) => left.name.localeCompare(right.name)); + +/** Per-candidate decision facts that both projections must carry. */ +const candidateStatus = (candidates: unknown) => + analysisProviderCandidateSummary.array().parse( + z + .array(z.record(z.string(), z.unknown())) + .parse(candidates) + .map(({ provider, selected, availability, target_support }) => { + const state = (value: unknown) => { + const { status, code, reason } = z + .object({ + status: z.string(), + code: z.unknown(), + reason: z.unknown(), + }) + .parse(value); + return { status, code, reason }; + }; + return { + provider, + selected, + availability: state(availability), + target_support: state(target_support), + }; + }), + ); + +const expectSummaryKeepsDecisionFacts = ( + summary: SessionStatus, + complete: SessionStatus, + open: boolean, +) => { + expect(summary.parsed).toMatchObject({ + open, + detail: "summary", + omitted_fields: [...BINARY_SESSION_SUMMARY_OMITTED_FIELDS], + }); + expect(complete.parsed.detail).toBe("complete"); + expect(Object.hasOwn(complete.parsed, "omitted_fields")).toBe(false); + expect(summary.bytes).toBeLessThan(complete.bytes); + expect(JSON.stringify(summary.parsed)).not.toContain('"diagnostics"'); + + // Summary candidates parse only as summaries and keep status, code and reason. + expect( + analysisProviderCandidateSummary + .array() + .parse(summary.parsed.analysis_provider_candidates), + ).toEqual(candidateStatus(complete.parsed.analysis_provider_candidates)); + // Route capabilities and the binding stay inline at both levels of detail. + expect(summary.parsed.capabilities).toEqual(complete.parsed.capabilities); + expect(summary.parsed.analysis_provider_binding).toEqual( + complete.parsed.analysis_provider_binding, + ); + + const summaryTools = toolAvailabilitySummaryInventory.parse( + summary.parsed.tool_availability, + ); + const completeTools = toolAvailabilityInventory.parse( + complete.parsed.tool_availability, + ); + expect(summaryTools.groups.length).toBeLessThan(completeTools.groups.length); + expect(toolStatus(summaryTools)).toEqual(toolStatus(completeTools)); + expect(toolStatus(summaryTools).map(({ name }) => name)).toEqual( + TOOL_CONTRACTS.map(({ name }) => name).toSorted((left, right) => + left.localeCompare(right), + ), + ); +}; + +const connectDetailFixture = async (starts: string[]) => { + const session = composeBinarySession( + new AnalysisProviderRegistry([ + candidate("alpha", starts), + candidate("beta", starts, false), + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "session-detail", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + return mcp; +}; + +describe("binary_session detail over MCP", () => { + it("defaults to a summary that keeps every decision fact and names what complete detail adds", async () => { + directory = await createTestTempDirectory("rea-session-detail-mcp-"); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + const starts: string[] = []; + const mcp = await connectDetailFixture(starts); + + for (const open of [false, true]) { + if (open) { + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "alpha" }, + }); + expect(opened.isError).not.toBe(true); + } + const summary = await sessionStatusOf(mcp, {}); + const explicit = await sessionStatusOf(mcp, { detail: "summary" }); + const complete = await sessionStatusOf(mcp, { detail: "complete" }); + expect(explicit.parsed).toEqual(summary.parsed); + expectSummaryKeepsDecisionFacts(summary, complete, open); + expect( + analysisProviderCandidate + .array() + .parse(complete.parsed.analysis_provider_candidates), + ).toMatchObject([ + { + provider: { id: "alpha" }, + selected: open, + availability: { + status: "available", + diagnostics: { executable_path: "/opt/alpha/bin/analyze" }, + }, + capabilities: [{ provider: { id: "alpha" } }], + }, + { + provider: { id: "beta" }, + selected: false, + availability: { + status: "unavailable", + code: "not_configured", + reason: "beta is not configured", + }, + }, + ]); + } + expect(starts).toEqual([]); + }); + + it("rejects results whose fields do not match their detail, canonically and as advertised", async () => { + const mcp = await connectDetailFixture([]); + const summary = (await sessionStatusOf(mcp, {})).raw.result; + const complete = (await sessionStatusOf(mcp, { detail: "complete" })).raw + .result; + const advertised = (await mcp.listTools()).tools.find( + ({ name }) => name === "binary_session", + )?.outputSchema; + if (advertised === undefined) + throw new Error("binary_session omitted its output schema"); + const validate = new AjvJsonSchemaValidator().getValidator( + z.record(z.string(), z.unknown()).parse(advertised), + ); + const canonical = toolContract("binary_session").outputSchema; + const { omitted_fields: _omitted, ...summaryWithoutOmissions } = summary; + const cases = { + summary, + complete, + "complete labelled summary": { ...complete, detail: "summary" }, + "summary labelled complete": { ...summary, detail: "complete" }, + "summary without omitted_fields": summaryWithoutOmissions, + "complete with omitted_fields": { + ...complete, + omitted_fields: summary.omitted_fields, + }, + "summary with complete candidates": { + ...summary, + analysis_provider_candidates: complete.analysis_provider_candidates, + }, + "summary with complete tool groups": { + ...summary, + tool_availability: complete.tool_availability, + }, + "complete with summary candidates": { + ...complete, + analysis_provider_candidates: summary.analysis_provider_candidates, + }, + "complete with summary tool groups": { + ...complete, + tool_availability: summary.tool_availability, + }, + }; + for (const [name, result] of Object.entries(cases)) { + const valid = name === "summary" || name === "complete"; + expect(canonical.safeParse({ result }).success, name).toBe(valid); + expect(validate({ result }).valid, name).toBe(valid); + } + }); + + it("rejects an unknown detail without starting a provider", async () => { + const starts: string[] = []; + const mcp = await connectDetailFixture(starts); + const rejected = await mcp.callTool({ + name: "binary_session", + arguments: { detail: "verbose" }, + }); + expect(rejected.isError).toBe(true); + expect(starts).toEqual([]); + }); +}); + const candidate = ( id: string, starts: string[], @@ -455,7 +714,10 @@ it.each(["ready", "cancelled", "physical"] as const)( if (mode !== "ready") { closing = session.close(); const during = structured( - await mcp.callTool({ name: "binary_session", arguments: {} }), + await mcp.callTool({ + name: "binary_session", + arguments: { detail: "complete" }, + }), ); expect(during.result).toMatchObject({ cleanup_pending: true, @@ -469,6 +731,23 @@ it.each(["ready", "cancelled", "physical"] as const)( }, ], }); + // The summary keeps the unknown status and its reason, not diagnostics. + const summary = structured( + await mcp.callTool({ name: "binary_session", arguments: {} }), + ); + expect(summary.result).toMatchObject({ + cleanup_pending: true, + analysis_provider_candidates: [ + { + provider: { id: "alpha" }, + availability: { + status: "unknown", + code: null, + reason: expect.stringContaining("finalization is in progress"), + }, + }, + ], + }); } await new Promise((resolve) => setImmediate(resolve)); expect(settled).toBe(false); diff --git a/tests/boundary/mcp/serverIdentity.test.ts b/tests/boundary/mcp/serverIdentity.test.ts index a8e4ef5057..0b637c3894 100644 --- a/tests/boundary/mcp/serverIdentity.test.ts +++ b/tests/boundary/mcp/serverIdentity.test.ts @@ -512,7 +512,7 @@ const activeTargetReasons = async ( const assertLiveIdentity = async (client: Client): Promise => { const identity = await client.callTool({ name: "binary_session", - arguments: {}, + arguments: { detail: "complete" }, }); const status = z .object({ result: sessionProvider }) diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 2371e9186f..c58679df32 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -30,7 +30,7 @@ import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js"; import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; import { expandProviderCapabilities, - toolAvailabilityInventory, + toolAvailabilitySummaryInventory, } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { ok as resultOk } from "../../../src/domain/result.js"; @@ -201,7 +201,7 @@ describe("public provider availability", () => { const status = structured( await mcp.callTool({ name: "binary_session", arguments: {} }), ); - const availability = toolAvailabilityInventory.parse( + const availability = toolAvailabilitySummaryInventory.parse( (status.result as { tool_availability: unknown }).tool_availability, ); expect( @@ -229,7 +229,7 @@ describe("target-free MCP fixture host isolation", () => { const status = structured( await mcp.callTool({ name: "binary_session", arguments: {} }), ); - const availability = toolAvailabilityInventory.parse( + const availability = toolAvailabilitySummaryInventory.parse( (status.result as { tool_availability: unknown }).tool_availability, ); for (const name of [ diff --git a/tests/boundary/process/mcpVerifierResults.test.ts b/tests/boundary/process/mcpVerifierResults.test.ts new file mode 100644 index 0000000000..1d7ba70ef5 --- /dev/null +++ b/tests/boundary/process/mcpVerifierResults.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "vitest"; + +interface VerifierResults { + readonly expandMcpSessionStatus: (status: unknown) => { + readonly analysis_provider_candidates: unknown; + }; + readonly expandProviderOutput: (providers: unknown) => { + readonly analysis_provider_candidates: unknown; + }; +} + +// The verifier helper imports the compiled contracts, so this runs after build. +const verifierResults = (): Promise => + import( + new URL("../../../scripts/lib/mcp-verifier-results.mjs", import.meta.url) + .href + ) as Promise; + +const provider = { id: "alpha", name: "alpha provider", version: null }; +const summaryCandidate = { + provider, + availability: { status: "available", code: null, reason: null }, + target_support: { status: "unknown", code: null, reason: "No target." }, + selected: false, +}; +const emptyTools = { + client_requirements: { + required: [], + optional: [], + missing_required: [], + missing_optional: [], + }, + groups: [], +}; + +it("keeps summary candidates only for MCP summary status", async () => { + const { expandMcpSessionStatus } = await verifierResults(); + expect( + expandMcpSessionStatus({ + detail: "summary", + capabilities: [], + analysis_provider_candidates: [summaryCandidate], + tool_availability: emptyTools, + }).analysis_provider_candidates, + ).toEqual([summaryCandidate]); + expect(() => + expandMcpSessionStatus({ + detail: "complete", + capabilities: [], + analysis_provider_candidates: [summaryCandidate], + tool_availability: emptyTools, + }), + ).toThrow(); +}); + +it("requires candidate capabilities in CLI provider output", async () => { + const { expandProviderOutput } = await verifierResults(); + expect(() => + expandProviderOutput({ + capabilities: [], + analysis_provider_candidates: [summaryCandidate], + }), + ).toThrow(); + expect( + expandProviderOutput({ + capabilities: [], + analysis_provider_candidates: [{ ...summaryCandidate, capabilities: [] }], + }).analysis_provider_candidates, + ).toEqual([{ ...summaryCandidate, capabilities: [] }]); +});