mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-8
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, MutableAvailabilityGroup>();
|
||||
const groups = new Map<string, { names: string[] }>();
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<JsonValue> {
|
||||
status(
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly detail?: BinarySessionDetail;
|
||||
} = {},
|
||||
): Promise<JsonValue> {
|
||||
const resourceRun = this.#activeBundleResources;
|
||||
const run =
|
||||
this.#active ??
|
||||
|
||||
@@ -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<ExecutionOptions, "progress">,
|
||||
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>>;
|
||||
status(options?: { readonly signal?: AbortSignal }): Promise<JsonValue>;
|
||||
/** Default complete detail; summary omits provider diagnostics and candidate capabilities. */
|
||||
status(options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly detail?: BinarySessionDetail;
|
||||
}): Promise<JsonValue>;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
/** Read a snapshot with failed descriptors retained for session cleanup retry. */
|
||||
readAnalysisSnapshot(
|
||||
|
||||
@@ -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<SessionProviderRouter, "candidateStatuses"> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<JsonValue> => {
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 extends z.ZodType>(schema: Schema) =>
|
||||
@@ -256,41 +257,48 @@ const providerRejectionCode: z.ZodType<ProviderRejectionCode> = 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<typeof toolAvailabilityFacts.annotations>)[];
|
||||
|
||||
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 extends z.ZodRawShape>(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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, import("../domain/jsonValue.js").JsonValue>
|
||||
>;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<unknown> }> = [];
|
||||
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<string, unknown>,
|
||||
) => {
|
||||
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<ReturnType<typeof sessionStatusOf>>;
|
||||
|
||||
/** Per-tool decision facts that both projections must carry. */
|
||||
const toolStatus = (inventory: Parameters<typeof expandToolAvailability>[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<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
@@ -512,7 +512,7 @@ const activeTargetReasons = async (
|
||||
const assertLiveIdentity = async (client: Client): Promise<void> => {
|
||||
const identity = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: {},
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
const status = z
|
||||
.object({ result: sessionProvider })
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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<VerifierResults> =>
|
||||
import(
|
||||
new URL("../../../scripts/lib/mcp-verifier-results.mjs", import.meta.url)
|
||||
.href
|
||||
) as Promise<VerifierResults>;
|
||||
|
||||
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: [] }]);
|
||||
});
|
||||
Reference in New Issue
Block a user