mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:49:25 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11b9760830 | ||
|
|
9fd5eca98a | ||
|
|
ed307b8d52 | ||
|
|
17e8deebfd | ||
|
|
454d7121f5 | ||
|
|
02243e21f3 | ||
|
|
4c1f2537e1 | ||
|
|
229ebd49d3 | ||
|
|
41493cf39b | ||
|
|
9304152a95 | ||
|
|
f1622b32d5 |
No files matched your search
@@ -2132,6 +2132,26 @@ check full-profile local-path rules. Compact MCP workflow tests verify canonical
|
||||
rejection of invalid inputs before provider work, independent of how much detail
|
||||
the selected advertisement retains.
|
||||
|
||||
### Retained-view guidance
|
||||
|
||||
`inspect_analysis_view` advertises the shared source, Evidence and paging rules.
|
||||
Its format-specific selectors are available on demand through the existing
|
||||
guidance tools: search with `{"query":"inspect_analysis_view <format or view kind>"}`,
|
||||
then pass a returned locator to `read_guidance_section`. For example, search for
|
||||
`inspect_analysis_view OpenAPI` before selecting retained API operations.
|
||||
These compiled tool sections ship in the runtime alongside the installed workflow
|
||||
guides; they use the same bundle digest, section selection and stale-locator checks.
|
||||
They are documentation, not target observations or Evidence. CLI callers use
|
||||
`rea guidance --action search --query "inspect_analysis_view OpenAPI"`, then
|
||||
`rea guidance --action read --locator <locator>` through the same owner.
|
||||
|
||||
In hot/cold catalogs, first discover `search_guidance` and `read_guidance_section`
|
||||
by their exact names. Keyword tool discovery also searches the compiled tool
|
||||
guidance, so moving a selector out of the advertised description does not remove
|
||||
its tool-discovery keywords. Full input/output schemas and canonical validation
|
||||
remain unchanged; a full-catalog client does not need catalog refresh to use the
|
||||
guidance tools.
|
||||
|
||||
<a id="hotcold-catalog-discovery"></a>
|
||||
|
||||
### Hot/cold catalog discovery
|
||||
@@ -2141,6 +2161,13 @@ clients that refresh `tools/list` after `notifications/tools/list_changed`.
|
||||
The default, `full`, retains the complete upfront advertisement. Restart the
|
||||
server to change profiles. See [client configuration](../getting-started/client-configuration.md#hotcold-catalogs).
|
||||
|
||||
Legacy connections receive list-change notifications unsolicited. On protocol
|
||||
revision `2026-07-28`, subscribe to tool-list changes with `subscriptions/listen`;
|
||||
the pinned JS SDK exposes `client.listen({toolsListChanged: true})` or the
|
||||
`ClientOptions.listChanged.tools` convenience handler. Registering a notification
|
||||
handler alone does not open a modern subscription. Refresh `tools/list` after
|
||||
the subscribed notification before calling newly loaded tools.
|
||||
|
||||
Hot/cold initially advertises `binary_session`, `open_binary`, `close_binary`,
|
||||
`get_evidence_bundle`, and the adapter's `discover_tools`.
|
||||
`inspect_analysis_view` is loaded on demand like other analysis tools. When a
|
||||
@@ -2150,7 +2177,7 @@ that accept `view` can answer a selected first question without loading the
|
||||
cross-domain view contract. Complete output schemas and canonical result
|
||||
validation are preserved when each tool loads.
|
||||
An explicit tool-family scope can remove any of those domain tools.
|
||||
`discover_tools` searches canonical names and descriptions using case-insensitive
|
||||
`discover_tools` searches canonical names, descriptions and compiled tool guidance using case-insensitive
|
||||
words, or accepts exact `names`. It loads the matching page and returns names
|
||||
and abbreviated descriptions with their configured input schemas, so the next
|
||||
call can use the returned arguments contract. Output schemas remain in
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect, it } from "vitest";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { readGuidanceSectionInputSchema } from "../contracts/guidanceContracts.js";
|
||||
import { MANAGED_SKILL_FILES } from "./SetupSkill.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import { parseGuidanceSections } from "./GuidanceSections.js";
|
||||
import {
|
||||
readGuidanceSectionWithReader,
|
||||
searchGuidanceWithReader,
|
||||
@@ -76,21 +78,33 @@ it("issues readable locators independently of heading length", async () => {
|
||||
await resources.close();
|
||||
});
|
||||
|
||||
it.each([2048, 2049])(
|
||||
"bounds the combined index across guides with %i sections each",
|
||||
it.each([4096, 4097])(
|
||||
"bounds the combined skill and tool index at %i sections",
|
||||
async (count) => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const compiled = parseGuidanceSections(
|
||||
new Map(
|
||||
Object.values(TOOL_GUIDANCE).map(({ guide, content }) => [
|
||||
guide,
|
||||
content,
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (compiled === undefined) throw new Error("Invalid compiled guidance");
|
||||
const remaining = count - compiled.length;
|
||||
const result = await searchGuidanceWithReader(
|
||||
{ query: "needle", limit: 1 },
|
||||
async (path) =>
|
||||
path === MANAGED_SKILL_FILES[0] || path === MANAGED_SKILL_FILES[1]
|
||||
? "# needle\n".repeat(count)
|
||||
: "",
|
||||
path === MANAGED_SKILL_FILES[0]
|
||||
? "# needle\n".repeat(Math.floor(remaining / 2))
|
||||
: path === MANAGED_SKILL_FILES[1]
|
||||
? "# needle\n".repeat(Math.ceil(remaining / 2))
|
||||
: "",
|
||||
resources,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { status: count === 2048 ? "matches" : "bundle_unavailable" },
|
||||
value: { status: count === 4096 ? "matches" : "bundle_unavailable" },
|
||||
});
|
||||
await resources.close();
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { MANAGED_SKILL_FILES, readSkillFile } from "./SetupSkill.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import {
|
||||
MAX_SUBSECTION_POINTERS,
|
||||
readGuidanceSectionInputSchema,
|
||||
@@ -32,6 +33,13 @@ const SKILL_ROOT = `../../skills/${PRODUCT_IDENTITY.skillName}/`;
|
||||
const SOURCE = "installed_rea_guidance" as const;
|
||||
const MAX_GUIDE_FILE_BYTES = 1_048_576;
|
||||
const MAX_GUIDE_BUNDLE_BYTES = 4_194_304;
|
||||
const COMPILED_GUIDES = new Map(
|
||||
Object.values(TOOL_GUIDANCE).map(({ guide, content }) => [guide, content]),
|
||||
);
|
||||
const GUIDANCE_FILES: ReadonlySet<string> = new Set([
|
||||
...MANAGED_SKILL_FILES,
|
||||
...COMPILED_GUIDES.keys(),
|
||||
]);
|
||||
const sha256 = (value: string | Uint8Array): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
@@ -85,8 +93,11 @@ const readBundle = async (
|
||||
resources: ArtifactResourceScope,
|
||||
operation: string,
|
||||
): Promise<GuidanceBundleRead> => {
|
||||
const files = new Map<string, string>();
|
||||
let bundleBytes = 0;
|
||||
const files = new Map(COMPILED_GUIDES);
|
||||
let bundleBytes = [...files.values()].reduce(
|
||||
(bytes, content) => bytes + Buffer.byteLength(content, "utf8"),
|
||||
0,
|
||||
);
|
||||
try {
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const content = await readGuide(relativePath);
|
||||
@@ -712,7 +723,7 @@ const decodeLocator = (locator: string): LocatorIdentity | undefined => {
|
||||
typeof item.bundle !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(item.bundle) ||
|
||||
typeof item.guide !== "string" ||
|
||||
!(MANAGED_SKILL_FILES as readonly string[]).includes(item.guide) ||
|
||||
!GUIDANCE_FILES.has(item.guide) ||
|
||||
!Number.isInteger(item.ordinal) ||
|
||||
typeof item.section !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(item.section)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { createCatalogIdentity } from "./catalogIdentity.js";
|
||||
import { TOOL_GUIDANCE } from "./toolGuidance.js";
|
||||
|
||||
// Retained views are loaded like producers: their full cross-domain output
|
||||
// contract is unnecessary until a task actually queries retained analysis.
|
||||
@@ -27,7 +28,7 @@ const inputSchema = z
|
||||
.trim()
|
||||
.optional()
|
||||
.describe(
|
||||
"Case-insensitive words matched against tool names and descriptions; all words must match. Omit to browse.",
|
||||
"Case-insensitive words matched against tool names, descriptions and bundled tool guidance; all words must match. Omit to browse.",
|
||||
),
|
||||
names: z
|
||||
.array(z.string().min(1))
|
||||
@@ -98,6 +99,7 @@ export const progressiveCatalogIdentity = (
|
||||
const policy = {
|
||||
profile: "hot-cold",
|
||||
hot: [...HOT_TOOL_NAMES],
|
||||
guidance: TOOL_GUIDANCE,
|
||||
tool: {
|
||||
...DISCOVER_TOOLS,
|
||||
inputSchema: z.toJSONSchema(inputSchema, { io: "input" }),
|
||||
|
||||
@@ -49,7 +49,7 @@ export const registerGuidanceCommands = (
|
||||
resources: CliGuidanceResources = processGuidanceResources,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.guidance, {
|
||||
description: "Search or read the installed REA workflow guidance",
|
||||
description: "Search or read the installed REA workflow and tool guidance",
|
||||
args: z.object({}),
|
||||
options: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/** Format-specific documentation, indexed on demand rather than advertised with every tool. */
|
||||
export const ANALYSIS_VIEW_GUIDANCE = `# Retained analysis view guidance
|
||||
|
||||
Each section documents \`inspect_analysis_view\` for a compatible completed parent.
|
||||
Use \`search_guidance\` with \`inspect_analysis_view\` and a view kind, then read the
|
||||
returned locator with \`read_guidance_section\`. These are instructions, not Evidence.
|
||||
|
||||
## inspect_analysis_view: Native procedure references
|
||||
|
||||
Use native-procedure-references to page retained procedure reference or unresolved-call
|
||||
rows. Endpoint kind selected-procedure refers to the summary procedure, null preserves
|
||||
the original null, and shared carries an index into items.endpoints, where each other
|
||||
procedure is stored once per page. Exact source_address and target_address filters apply
|
||||
to reference rows.
|
||||
|
||||
## inspect_analysis_view: OpenAPI and GraphQL contracts
|
||||
|
||||
For inspect_api_contract OpenAPI parents, use kind api-contract, collection operations,
|
||||
with exact methods/paths, selected fields and matching-row pagination. For example:
|
||||
view {kind: "api-contract", collection: "operations", paths: ["/items"],
|
||||
fields: ["method", "path"], offset: 0, limit: 20}. This reads retained declarations;
|
||||
page exhaustion does not establish runtime endpoint behavior.
|
||||
|
||||
For inspect_api_contract parents, graphql-contract pages retained SDL types/fields,
|
||||
operation definitions, field references, fragment-spreads or diagnostics with exact
|
||||
filters and selected columns.
|
||||
|
||||
## inspect_analysis_view: JavaScript routes
|
||||
|
||||
Use javascript-routes collections to query route patterns, source-linked occurrences,
|
||||
source status, unknowns or summary counts from retained JavaScript application or
|
||||
web-bundle Evidence.
|
||||
|
||||
## inspect_analysis_view: Web bundle endpoints
|
||||
|
||||
Use web-bundle collections endpoints or inferences to page endpoint observations and
|
||||
inferences from retained analyze_web_bundle Evidence; summary returns target URL,
|
||||
origin, and counts.
|
||||
|
||||
## inspect_analysis_view: Native feature traces
|
||||
|
||||
For retained trace_feature Evidence, kind native-feature-trace pages matches, references
|
||||
or unknowns; matches filter exact address, references filter exact source_address and
|
||||
target_address, source_index identifies each original row, and list_unknowns pages
|
||||
durable question heads.
|
||||
|
||||
## inspect_analysis_view: Recorded arrays and pseudocode
|
||||
|
||||
Inspect selected views of supported completed Evidence without repeating analysis. Use
|
||||
recorded-array with an exact raw_result object-member path to page a provider-recorded
|
||||
array when the producer supplies that path; rows keep their raw meaning and the parent
|
||||
retains coverage. For procedure_pseudo_code Evidence, summary reports text availability
|
||||
and the original selection; kind native with facet pseudocode pages the original UTF-16
|
||||
text. Other native facets require completed analyze_function Evidence. Select a view
|
||||
compatible with the original parent operation; inspect_analysis_view does not rerun it.
|
||||
|
||||
## inspect_analysis_view: Historical command and capture records
|
||||
|
||||
Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once,
|
||||
source parameters, completion facts and parent identity. They do not establish current
|
||||
target state. Historical capture summaries return identity and counts without bodies or
|
||||
archive inventories. Capture views page retained records: view {kind: "capture", detail:
|
||||
"preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields
|
||||
and binary descriptors, complete returns exact records with all sidecars. Offsets follow
|
||||
the selected parent record order, preserving original producer ordinals. Paging never
|
||||
reopens the source or reruns the decoder.
|
||||
|
||||
## inspect_analysis_view: Native export tables
|
||||
|
||||
Native export tables use kind native-exports, collection exports/issues, exact names or
|
||||
original indices, selected fields and matching-row pagination; parent Evidence plus
|
||||
source_index identifies each row. Source coverage stays separate from page coverage.
|
||||
|
||||
## inspect_analysis_view: Erlang BEAM
|
||||
|
||||
BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact
|
||||
names/indices/fields and matching-row pages; import modules intersects function names.
|
||||
source_index identifies original parent-and-collection rows, preserving every arity and
|
||||
raw atom index. beam.counts preserves null missing tables versus decoded empty tables;
|
||||
runtime absence remains unknown.
|
||||
|
||||
## inspect_analysis_view: Python PYC
|
||||
|
||||
PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging;
|
||||
source_index scopes every same-name alternative to the parent, nested_code keeps
|
||||
code-constant references, and pyc.coverage preserves source coverage.
|
||||
|
||||
## inspect_analysis_view: Go metadata
|
||||
|
||||
Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices,
|
||||
fields and pagination. Row index refers to the original collection; empty selections
|
||||
return no rows, and dependency coverage remains unknown.
|
||||
|
||||
## inspect_analysis_view: Rust dependencies
|
||||
|
||||
Rust metadata uses kind rust, collection packages/edges, exact names/versions or
|
||||
original indices, selected fields, edge from_indices/to_indices and matching-row
|
||||
pagination. Embedded-record coverage is separate from unknown runtime dependencies.
|
||||
|
||||
## inspect_analysis_view: PCAP captures
|
||||
|
||||
For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni,
|
||||
protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses,
|
||||
or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained
|
||||
facts; source coverage remains explicit.
|
||||
|
||||
## inspect_analysis_view: Qlog traces
|
||||
|
||||
Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams,
|
||||
loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except
|
||||
traces, select trace_index; stream-specific facets also require exact stream_id and
|
||||
direction. Facts retain all-recorded-events-before-selection scope, and interval overlap
|
||||
or endpoint loss declarations do not prove retransmission or network loss.
|
||||
|
||||
## inspect_analysis_view: API surface
|
||||
|
||||
The api-surface view groups retained inspect_web_network_capture HAR requests by method,
|
||||
origin and format-rule path
|
||||
template; templated segments are inferences. It reads no headers or bodies and reports
|
||||
no query values.
|
||||
|
||||
## inspect_analysis_view: Environment manifest
|
||||
|
||||
The environment-manifest view projects retained inspect_web_network_capture HAR requests
|
||||
on the primary origin (most
|
||||
JSON-like records) into a replayable tool surface: view {kind: "environment-manifest",
|
||||
template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"],
|
||||
include_query_parameter_names: true}. Each tool has a deterministic name, a path
|
||||
template and parameters from the four format rules plus the selected opt-in rules,
|
||||
observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and
|
||||
content-type sets and record ordinals; records are pointers into the parent, excluded
|
||||
groups carry a reason and three fixed unknowns are always stated. Templates, parameters
|
||||
and names are inferences; the view copies no header, cookie or body values and refuses
|
||||
HAR parents whose sensitivity declarations are missing or record sensitive values.
|
||||
Do not remove sensitivity declarations to bypass this refusal.
|
||||
|
||||
For capture_browser_scenario Evidence, environment-manifest requires
|
||||
capture.network.response_body or header_values to have been selected. It projects one
|
||||
record per network transaction in request order, relies on capture-time redaction of
|
||||
declared secrets, and leaves a transaction whose URL was redacted unrepresented.
|
||||
|
||||
## inspect_analysis_view: Browser scenario captures
|
||||
|
||||
Retained capture_browser_scenario Evidence supports summary and browser-scenario
|
||||
steps/events/transactions/text pages, preserving step_indices, event_kinds,
|
||||
transaction_ids and selected artifacts. For an environment-manifest projection, search
|
||||
for "inspect_analysis_view Environment manifest" and read its section; that projection
|
||||
has separate capture selection and redaction requirements.
|
||||
|
||||
## inspect_analysis_view: Environment state
|
||||
|
||||
The environment-state view extracts initial state from retained inspect_web_network_capture
|
||||
HAR or capture_browser_scenario Evidence. Use view {kind: "environment-state",
|
||||
template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"]}, selecting
|
||||
the same optional template_rules as the accompanying environment-manifest. It reads
|
||||
response bodies recorded before the
|
||||
first related write and reports entity types with an identity field (proved by a path
|
||||
parameter or inferred from a field name), entities in collection order with per-view
|
||||
values where views differ, documents for non-parameterized reads that are not entities
|
||||
and have one initial request line (item-shaped paths stay recorded), and a render plan
|
||||
per read tool: item and list views reproduce their latest recordings from the state (a
|
||||
list also learns the filters, search, sort and paging its recordings verify, and answers
|
||||
new values of them from the state), and every other read tool is a recorded view with a
|
||||
reason. It also reports a write plan per state-changing tool (create, update, delete or
|
||||
document, built from its first successful call) and write_clock; a plan is served only
|
||||
when replaying the capture shows a later read confirming its effect and none
|
||||
contradicting it. Types and identity are inferences; values are recorded data, so it
|
||||
refuses HAR parents whose sensitivity declarations are missing or record sensitive
|
||||
values. Do not remove sensitivity declarations to bypass this refusal. A scenario
|
||||
parent must have selected capture.network.response_body; its values keep the capture
|
||||
redaction markers, and a write
|
||||
whose URL it redacted ends the state of every type.
|
||||
|
||||
## inspect_analysis_view: Environment coverage
|
||||
|
||||
The environment-coverage view summarizes initial-state projection from retained
|
||||
inspect_web_network_capture HAR or capture_browser_scenario Evidence without returning
|
||||
its entities. Use view {kind: "environment-coverage", template_rules:
|
||||
["after-uid-literal", "opaque-token", "sibling-variance"]}, selecting the same optional
|
||||
template_rules as the accompanying environment-manifest and environment-state views.
|
||||
reads.state and reads.recorded count
|
||||
read tools answered from the state and replayed (recorded_reasons per view reason),
|
||||
writes.served lists the served write tools, and writes.withheld lists each other write
|
||||
tool with its reason, plan record, contradicting read and a hint about what to record
|
||||
next. It refuses HAR parents whose sensitivity declarations are missing or record
|
||||
sensitive values; do not remove sensitivity declarations to bypass this refusal.
|
||||
A scenario parent must have selected capture.network.response_body, and retained
|
||||
capture-time redaction markers remain authoritative. For state modelling and write
|
||||
confirmation rules, search for "inspect_analysis_view Environment state" and read
|
||||
its section.
|
||||
|
||||
## inspect_analysis_view: Sources and summaries
|
||||
|
||||
For a retained record use source: {kind: "retained-evidence", evidence_id:
|
||||
"<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary
|
||||
receipt. Source kind is required. Portable source uses {kind: "inline", evidence:
|
||||
<complete Evidence>}. A completed Evidence JSON file on this host uses {kind:
|
||||
"evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects
|
||||
it like retained Evidence, so a large capture is bounded by the file reader rather than
|
||||
by inline-input accounting. The file may also be an Evidence bundle written by CLI
|
||||
--evidence_output or export_evidence_bundle; select evidence_id when it contains more
|
||||
than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder,
|
||||
EVM, managed artifact, Android package and Dart AOT sources, request a summary with
|
||||
view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives
|
||||
validation, counts, WAT digest/length, tool profile and execution/network states without
|
||||
reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module,
|
||||
or a stable page.
|
||||
|
||||
## inspect_analysis_view: JavaScript modules and facts
|
||||
|
||||
For an exact module's retained facts and source locations, search guidance for
|
||||
"read module without repeating analysis" and follow that retained-parent workflow.
|
||||
|
||||
Module pages include assets, bundled and source modules; use node_id for ambiguous or
|
||||
unavailable paths. Typed module references use view {kind: "javascript", collection:
|
||||
"modules", projection: "references", where: {path: "main.js", kind:
|
||||
"javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null
|
||||
selects unavailable paths). Rows include canonical identity, retained source-range count
|
||||
and observation support states. Source coverage and selection remain explicit;
|
||||
exhausting matching rows does not prove runtime loading or complete analysis. For exact
|
||||
file/native facts and processing gaps, use view {kind: "javascript-facts", where:
|
||||
{paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}.
|
||||
Rows page original observations with canonical identities and Evidence; missing
|
||||
properties remain omitted. Paths match recorded artifact-path/source-range locations;
|
||||
optional kinds filter nodes. To discover field names without values, opt into
|
||||
include_available_property_keys: true with property_keys: []. Canonical identity and
|
||||
Evidence are separate from properties; keep each node_id/observation_id pair together
|
||||
when composing results.
|
||||
|
||||
## inspect_analysis_view: Artifact inventory
|
||||
|
||||
Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths:
|
||||
["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges
|
||||
or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids;
|
||||
nodes accept artifact_ids. Batch filters intersect (omitted means all available, []
|
||||
means none); fields selects columns while retaining identity links. source_complete
|
||||
describes retained graph membership; source_coverage describes producer coverage,
|
||||
including unknown encrypted PCK directories. Page exhaustion does not prove absence in a
|
||||
partial source.
|
||||
|
||||
## inspect_analysis_view: WebAssembly
|
||||
|
||||
WASM views use kind wasm and facet
|
||||
sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets
|
||||
use UTF-16 code units and source forms remain exact.
|
||||
|
||||
## inspect_analysis_view: Mach-O
|
||||
|
||||
Mach-O views use kind macho and facet
|
||||
entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance;
|
||||
source_exhaustive false means unreturned rows may exist.
|
||||
|
||||
## inspect_analysis_view: Interface Builder
|
||||
|
||||
Interface Builder views use kind interface-builder and collection
|
||||
documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path.
|
||||
|
||||
## inspect_analysis_view: EVM interfaces
|
||||
|
||||
EVM summary returns all inferred interface candidates, carrier/decoded identities and
|
||||
explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet
|
||||
functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate
|
||||
rows, and text offsets/limits count UTF-16 code units (hex has two characters per
|
||||
decoded byte). Exhausting candidate pages does not establish global interface
|
||||
completeness.
|
||||
|
||||
## inspect_analysis_view: Managed artifacts and members
|
||||
|
||||
Managed artifact summaries preserve complete identity/classification, metadata status,
|
||||
module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed
|
||||
pages use kind managed and collection references/resources/attributes, retaining source
|
||||
coverage and build-local token scope; page exhaustion does not establish complete
|
||||
metadata. Managed member summaries keep identity, selection with unmatched names,
|
||||
coverage and per-collection counts; managed-members pages use collection
|
||||
types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.
|
||||
Exact names and declaring_types filters intersect before paging where supported; fields
|
||||
selects columns (distinct from collection: "fields"). Identity/source_index, module
|
||||
MVID, source coverage and unresolved filter counts remain; omit body to avoid IL
|
||||
anchors. Omitted fields preserves full rows.
|
||||
|
||||
## inspect_analysis_view: Android manifests
|
||||
|
||||
Android package summaries retain all declarations, engine identity and counts without
|
||||
decoded manifest text. Android manifest pages use kind android, facet manifest, offset
|
||||
and limit in UTF-16 code units; source_status preserves partial decoded coverage even
|
||||
when the retained text page is exhausted.
|
||||
|
||||
## inspect_analysis_view: Dart AOT
|
||||
|
||||
Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower
|
||||
bounds. String pages use kind dart-aot, collection
|
||||
package_uris/dart_uris/class_like_tokens; source coverage remains partial when the
|
||||
producer says partial.
|
||||
|
||||
## inspect_analysis_view: Protobuf
|
||||
|
||||
Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals,
|
||||
body_pointers and fields; field pages also select frame_indices, field_ids,
|
||||
field_numbers or schema_names. Byte identity, offset basis and body status remain
|
||||
present. Text and schema_value are opt-in page columns.
|
||||
|
||||
## inspect_analysis_view: APK signatures
|
||||
|
||||
APK signing views use kind apk-signature and collection signers/digests/lineage, with
|
||||
ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty.
|
||||
Declared identities and digest matches do not establish signature verification or
|
||||
authenticity.
|
||||
|
||||
## inspect_analysis_view: Model artifacts
|
||||
|
||||
Model views use kind model-artifact and collection metadata (keys), tensors
|
||||
(names/types) or types (derived totals), with indices, fields and paging. Header SHA-256
|
||||
scopes declarations; full-file identity remains unknown unless full hashing was
|
||||
selected. Arrays retain typed eight-element previews with explicit omissions.
|
||||
|
||||
## inspect_analysis_view: IL2CPP metadata
|
||||
|
||||
IL2CPP metadata views use kind il2cpp-metadata and collection
|
||||
sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues.
|
||||
Select indices, fields and pages;
|
||||
sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances
|
||||
accept exact names (native type names match retained rendered values, preserving
|
||||
same-name rows; null names never match), types also accept
|
||||
image_indices/namespaces/declaring_types, and methods/members accept declaring_types.
|
||||
Parameters and parameter_types accept method_indices; code_gen_modules accept
|
||||
image_indices; method_addresses and generic_method_instances accept addresses/states
|
||||
(generic_method_instances also method_indices), preserving every folded alternative;
|
||||
generic_method_issues accept rows. Row index is scoped to the metadata digest; native
|
||||
pages add binary_source. Uncollected native tables have null counts and remain
|
||||
unavailable.
|
||||
|
||||
## inspect_analysis_view: Desktop directories
|
||||
|
||||
Desktop directory views use kind desktop-application and collection
|
||||
files/omissions/candidates/components/next_probes, with fields and pagination; files and
|
||||
omissions accept exact paths or their stable IDs, and candidates accept families. Source
|
||||
coverage and stable row IDs remain available.
|
||||
|
||||
## inspect_analysis_view: Native load images
|
||||
|
||||
Native load-image Evidence uses kind native-load-image with collection
|
||||
source_files/mappings/relocations/entry_points/entry_context/checks/sections, original
|
||||
indices, collection-specific fields and offset/limit paging. Null collections remain
|
||||
unrecorded or inapplicable; pages preserve unsupported verification.
|
||||
|
||||
## inspect_analysis_view: Native inventories
|
||||
|
||||
Native inventory Evidence from list_names, list/search_procedures or list/search_strings
|
||||
uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows
|
||||
keep source_index and address even with selected fields. Summary counts describe
|
||||
retained rows; provider discovery coverage remains unknown.
|
||||
|
||||
## inspect_analysis_view: Native functions
|
||||
|
||||
Native function Evidence accepts kind summary for identity, availability and facet
|
||||
counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}.
|
||||
Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees,
|
||||
references, unresolved calls, referenced strings/names, native_api and value-flow
|
||||
observations. For parameter type paths when value flow is recorded, use facet:
|
||||
"value_flow_parameters"; its decompiler-observed path may be passed to
|
||||
inspect_native_data_type, but exact database lookup can be unavailable and does not by
|
||||
itself establish type identity. Use nonnegative offset and a positive caller-selected
|
||||
limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use
|
||||
rows.
|
||||
|
||||
## inspect_analysis_view: Batch decompilation
|
||||
|
||||
Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view:
|
||||
{kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable",
|
||||
"error"]}} for body-free rows with exact indices, addresses, procedure_addresses or
|
||||
statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to
|
||||
page one body.
|
||||
|
||||
## inspect_analysis_view: Evidence and transport
|
||||
|
||||
Returns artifact identity, parent Evidence ID, a distinct view digest, coverage,
|
||||
limitations and unknowns. Actual serialized size determines MCP transport admission;
|
||||
choose a smaller page or export the complete retained Evidence if needed.
|
||||
`;
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Read summaries, selected facts, or pages from completed Evidence without rerunning analysis. Use the producing tool’s retained-view handoff for compatible selectors; the examples here are not exhaustive. Copy normalized_result.parent_evidence_id from a summary into source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Other sources are {kind: "inline", evidence: <complete Evidence>} or {kind: "evidence-file", path: "<absolute JSON path>", evidence_id?: "<record ID>"}; select an ID for a multi-record bundle. For native pseudocode use {source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}, view: {kind: "native", facet: "pseudocode", offset: 0, limit: 128}}; procedure_pseudo_code parents support this text facet, while other native function facets require analyze_function. Common views: {kind: "summary"} has no facet; {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64} selects artifact paths; {kind: "capture", detail: "preview", offset: 0, limit: 20} pages saved network records; {kind: "managed-members", collection: "methods", offset: 0, limit: 64} pages managed members. Use the producer’s named kind for other families; compact schemas may omit nested shapes, so do not invent selectors. Text offsets/limits use UTF-16 code units; row pages count the selected retained collection. Preserve parent identity, null/unavailable values, source coverage, limitations and unknowns; exhausting pages does not prove complete discovery, runtime use or absence. Smaller pages can resolve delivery limits without repeating analysis; complete parent Evidence remains available for export.',
|
||||
'Inspect selected views of completed Evidence without rerunning analysis or reopening its source. Use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}; summary receipts name the complete parent in normalized_result.parent_evidence_id. Portable sources use {kind: "inline", evidence: <complete Evidence>} or {kind: "evidence-file", path: "<absolute path>", evidence_id?: "<id in bundle>"}. Source kind is required. Choose a view compatible with the parent operation. For format-specific kinds, selectors and examples, call search_guidance with query "inspect_analysis_view <format or view kind>", then read_guidance_section with the returned locator; hot/cold clients discover those named tools first. Compact schemas may omit nested shapes; do not invent selectors. Pagination preserves parent identity, source coverage, limitations and unknowns; exhausting a page does not establish complete source discovery. Text offsets use UTF-16 code units where documented. Reduce the selected page or export complete retained Evidence if the reply exceeds the transport budget.',
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -102,7 +102,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
name: "search_guidance",
|
||||
...toolContractMetadata("search_guidance"),
|
||||
description:
|
||||
"Find installed REA workflow guidance by query words. Returns ranked sections, pages and locators for read_guidance_section; every non-generic word must match, with direct section matches ranked before matches spread across children. Reads documentation only, without accessing a target or provider.",
|
||||
"Find installed REA workflow and tool guidance by query words. Returns ranked sections, pages and locators for read_guidance_section; every non-generic word must match, with direct section matches ranked before matches spread across children. Reads documentation only, without accessing a target or provider.",
|
||||
kind: "application",
|
||||
inputSchema: searchGuidanceInputSchema,
|
||||
outputSchema: searchGuidanceOutputSchema,
|
||||
|
||||
@@ -10,7 +10,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
const connect = async (
|
||||
options: { replyBytes?: number; bulk?: boolean } = {},
|
||||
options: { replyBytes?: number; bulk?: boolean; guidance?: boolean } = {},
|
||||
) => {
|
||||
const bulkNames = options.bulk
|
||||
? Array.from(
|
||||
@@ -44,6 +44,7 @@ const connect = async (
|
||||
"alpha_sqlite",
|
||||
"beta_sqlite",
|
||||
"excluded_sqlite",
|
||||
...(options.guidance ? ["inspect_analysis_view"] : []),
|
||||
...bulkNames,
|
||||
]) {
|
||||
const registration = server.registerTool(
|
||||
@@ -188,6 +189,23 @@ it("loads exact names atomically and emits one notification for a batch", async
|
||||
expect(f.changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finds deferred tool guidance keywords without advertising that guidance", async () => {
|
||||
const f = await connect({ guidance: true });
|
||||
const found = await f.discover({ query: "generic_method_issues" });
|
||||
expect(found.isError).not.toBe(true);
|
||||
expect(found.structuredContent).toMatchObject({
|
||||
total_matches: 1,
|
||||
tools: [{ name: "inspect_analysis_view" }],
|
||||
});
|
||||
const tool = (await f.client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_analysis_view",
|
||||
);
|
||||
expect(tool?.description).toBe(
|
||||
"inspect_analysis_view SQLite database inspection",
|
||||
);
|
||||
expect(f.changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ names: [] },
|
||||
{ names: ["alpha_sqlite"], query: "sqlite" },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { ownedJsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { encodeToolResult } from "./toolResultEncoding.js";
|
||||
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import {
|
||||
DISCOVER_TOOLS,
|
||||
HOT_TOOL_NAMES,
|
||||
@@ -40,7 +41,7 @@ export class ProgressiveToolCatalog {
|
||||
registered,
|
||||
description: (registered.description ?? "").slice(0, 240),
|
||||
search:
|
||||
`${name} ${name.replaceAll("_", " ")} ${registered.description ?? ""}`.toLowerCase(),
|
||||
`${name} ${name.replaceAll("_", " ")} ${registered.description ?? ""} ${TOOL_GUIDANCE[name]?.content ?? ""}`.toLowerCase(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ANALYSIS_VIEW_GUIDANCE } from "./contracts/analysisViewGuidance.js";
|
||||
|
||||
/** Compiled documentation shares guidance retrieval and catalog search ownership. */
|
||||
export const TOOL_GUIDANCE: Readonly<
|
||||
Record<string, { readonly guide: string; readonly content: string }>
|
||||
> = {
|
||||
inspect_analysis_view: {
|
||||
guide: "tools/inspect_analysis_view.md",
|
||||
content: ANALYSIS_VIEW_GUIDANCE,
|
||||
},
|
||||
};
|
||||
@@ -9,8 +9,14 @@ import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.j
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js";
|
||||
import { TOOL_GUIDANCE } from "../../../src/toolGuidance.js";
|
||||
import { parseGuidanceSections } from "../../../src/application/GuidanceSections.js";
|
||||
import {
|
||||
searchGuidanceOutputSchema,
|
||||
readGuidanceSectionOutputSchema,
|
||||
} from "../../../src/contracts/guidanceContracts.js";
|
||||
|
||||
it("loads retained analysis views only when needed, preserving complete schemas, source facts and Evidence", async () => {
|
||||
const verifyGuidanceAndRetainedView = async (profile: "full" | "hot-cold") => {
|
||||
const root = await createTestTempDirectory("rea-progressive-api-");
|
||||
const path = join(root, "api.json");
|
||||
await writeFile(
|
||||
@@ -22,12 +28,14 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
}),
|
||||
);
|
||||
const { client } = await connectLocalToolsMcp({
|
||||
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
|
||||
REA_MCP_TOOL_CATALOG_PROFILE: profile,
|
||||
REA_MCP_TOOL_FAMILIES: "application,session",
|
||||
});
|
||||
const names = async () =>
|
||||
(await client.listTools()).tools.map((tool) => tool.name);
|
||||
expect(await names()).not.toContain("inspect_analysis_view");
|
||||
expect((await names()).includes("inspect_analysis_view")).toBe(
|
||||
profile === "full",
|
||||
);
|
||||
const changed = vi.fn();
|
||||
client.setNotificationHandler("notifications/tools/list_changed", changed);
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
@@ -35,7 +43,10 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
||||
return result;
|
||||
};
|
||||
await call("discover_tools", { names: ["inspect_api_contract"] });
|
||||
const load = async (names: string[]) => {
|
||||
if (profile === "hot-cold") await call("discover_tools", { names });
|
||||
};
|
||||
await load(["inspect_api_contract"]);
|
||||
expect(await names()).toContain("inspect_api_contract");
|
||||
const view = {
|
||||
kind: "api-contract",
|
||||
@@ -51,12 +62,53 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
kind: "api-contract",
|
||||
items: [{ method: "GET", path: "/items", state: "declared" }],
|
||||
});
|
||||
expect(await names()).not.toContain("inspect_analysis_view");
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
expect((await names()).includes("inspect_analysis_view")).toBe(
|
||||
profile === "full",
|
||||
);
|
||||
expect(changed).toHaveBeenCalledTimes(profile === "hot-cold" ? 1 : 0);
|
||||
await rm(path);
|
||||
await call("discover_tools", {
|
||||
names: ["inspect_analysis_view", "export_evidence_bundle"],
|
||||
await load(["search_guidance", "read_guidance_section"]);
|
||||
const found = await call("search_guidance", {
|
||||
query: "inspect_analysis_view OpenAPI",
|
||||
});
|
||||
// Legacy MCP wraps root unions; auto-negotiated results use the object directly.
|
||||
const unwrap = (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "result" in value
|
||||
? value.result
|
||||
: value;
|
||||
const matches = searchGuidanceOutputSchema.parse(
|
||||
unwrap(found.structuredContent),
|
||||
);
|
||||
if (matches.status !== "matches") throw new Error("Expected tool guidance");
|
||||
const selectedGuide = matches.matches[0];
|
||||
const guidance = TOOL_GUIDANCE.inspect_analysis_view!;
|
||||
expect(selectedGuide?.guide).toBe(guidance.guide);
|
||||
const read = await call("read_guidance_section", {
|
||||
locator: selectedGuide?.locator,
|
||||
});
|
||||
const section = readGuidanceSectionOutputSchema.parse(
|
||||
unwrap(read.structuredContent),
|
||||
);
|
||||
if (section.status !== "section") throw new Error("Expected a guide section");
|
||||
const owner = parseGuidanceSections(
|
||||
new Map([[guidance.guide, guidance.content]]),
|
||||
)?.find(({ heading }) => heading === selectedGuide?.heading);
|
||||
expect(owner).toBeDefined();
|
||||
expect(section.content).toBe(owner?.content);
|
||||
if (profile === "hot-cold") {
|
||||
const miss = await call("discover_tools", {
|
||||
query: "inspect_analysis_view qzxv",
|
||||
});
|
||||
expect(miss.structuredContent).toMatchObject({ total_matches: 0 });
|
||||
// A keyword formerly present only in the oversized description still finds the view.
|
||||
const foundView = await call("discover_tools", {
|
||||
query: "generic_method_issues",
|
||||
});
|
||||
expect(foundView.structuredContent).toMatchObject({
|
||||
tools: [expect.objectContaining({ name: "inspect_analysis_view" })],
|
||||
});
|
||||
}
|
||||
await load(["inspect_analysis_view", "export_evidence_bundle"]);
|
||||
const loaded = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_analysis_view",
|
||||
);
|
||||
@@ -65,7 +117,10 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
"~standard"
|
||||
].jsonSchema.output({ target: "draft-2020-12" }),
|
||||
);
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
expect(loaded?.description).toBe(
|
||||
toolContract("inspect_analysis_view").description,
|
||||
);
|
||||
expect(changed).toHaveBeenCalledTimes(profile === "hot-cold" ? 4 : 0);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: selected.parent_evidence_id,
|
||||
@@ -93,6 +148,11 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
expect(follow.structuredContent).toHaveProperty("evidence_links", [
|
||||
selected.parent_evidence_id,
|
||||
]);
|
||||
await call("discover_tools", { names: ["inspect_analysis_view"] });
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
await load(["inspect_analysis_view"]);
|
||||
expect(changed).toHaveBeenCalledTimes(profile === "hot-cold" ? 4 : 0);
|
||||
};
|
||||
|
||||
it.each(["full", "hot-cold"] as const)(
|
||||
"retrieves view guidance and retained facts with the %s catalog, preserving complete schemas and Evidence",
|
||||
verifyGuidanceAndRetainedView,
|
||||
);
|
||||
@@ -9,7 +9,7 @@ it("finds and retrieves the selected section from the generated package bundle",
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await searchGuidance(
|
||||
{ query: "exact JavaScript module" },
|
||||
{ query: "exact JavaScript module without repeating analysis" },
|
||||
resources,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
@@ -52,6 +52,47 @@ it("finds and retrieves the selected section from the generated package bundle",
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps workflow and selector guidance reachable for a broad module query", async () => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await searchGuidance(
|
||||
{ query: "exact JavaScript module" },
|
||||
resources,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
if (result.value.status !== "matches")
|
||||
throw new Error("Expected guidance matches");
|
||||
const workflow = result.value.matches.find(
|
||||
({ guide, heading }) =>
|
||||
guide === "references/javascript-applications.md" &&
|
||||
heading === "Read a module without repeating analysis",
|
||||
);
|
||||
expect(workflow).toBeDefined();
|
||||
const selectors = result.value.matches.find(
|
||||
({ guide, heading }) =>
|
||||
guide === "tools/inspect_analysis_view.md" &&
|
||||
heading === "inspect_analysis_view: JavaScript modules and facts",
|
||||
);
|
||||
expect(selectors).toBeDefined();
|
||||
if (selectors === undefined) throw new Error("Expected selector guidance");
|
||||
const read = await readGuidanceSection(
|
||||
{ locator: selectors.locator },
|
||||
resources,
|
||||
);
|
||||
if (!read.ok) throw read.error;
|
||||
expect(read.value).toMatchObject({
|
||||
status: "section",
|
||||
guide: selectors.guide,
|
||||
heading: selectors.heading,
|
||||
content: expect.stringContaining(
|
||||
"read module without repeating analysis",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("finds focused installed guidance for switching providers with annotations", async () => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import {
|
||||
Client,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
type Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { cp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs";
|
||||
import { HOT_TOOL_NAMES } from "../../../src/catalogDiscovery.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
readGuidanceSectionOutputSchema,
|
||||
searchGuidanceOutputSchema,
|
||||
} from "../../../src/contracts/guidanceContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.js";
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { SERVER_START_FAILED } from "../../../src/main/messages.js";
|
||||
|
||||
const validation = createMcpSchemaValidation();
|
||||
afterAll(() => validation.clear());
|
||||
|
||||
type Configuration = {
|
||||
mode: "legacy" | "auto";
|
||||
input: "full" | "compact";
|
||||
output: "full" | "compact" | "off";
|
||||
catalog: "full" | "hot-cold";
|
||||
};
|
||||
|
||||
// Pairwise inputs across the protocol/output/catalog product: twelve independent
|
||||
// clients, not an exhaustive 24-cell product. Compact/off output intentionally
|
||||
// use eager composition; full output must remain deferred until a domain call.
|
||||
const matrix: Configuration[] = ["legacy", "auto"].flatMap((mode, m) =>
|
||||
["full", "compact", "off"].flatMap((output, o) =>
|
||||
["full", "hot-cold"].map((catalog, c) => ({
|
||||
mode: mode as Configuration["mode"],
|
||||
output: output as Configuration["output"],
|
||||
catalog: catalog as Configuration["catalog"],
|
||||
input: (m + o + c) % 2 === 0 ? "full" : "compact",
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
const lifecycleConfigurations = [
|
||||
{ mode: "legacy", input: "full", output: "full", catalog: "full" },
|
||||
{ mode: "auto", input: "compact", output: "full", catalog: "hot-cold" },
|
||||
] as const;
|
||||
|
||||
const connect = async (
|
||||
configuration: Configuration,
|
||||
nodeArguments: readonly string[] = [],
|
||||
entrypoint = resolve("scripts/rea.mjs"),
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-deferred-schema-profiles-");
|
||||
const log = join(root, "runtime-loaded");
|
||||
await writeFile(log, "");
|
||||
const hook = `data:text/javascript,${encodeURIComponent(`
|
||||
import { registerHooks } from "node:module";
|
||||
import { appendFileSync } from "node:fs";
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const loaded = next(url, context);
|
||||
if (url.endsWith("/dist/main/eagerRuntime.js") ||
|
||||
loaded.source?.toString().includes("//#region dist/main/eagerRuntime.js\\n"))
|
||||
appendFileSync(${JSON.stringify(log)}, "loaded\\n");
|
||||
return loaded;
|
||||
}
|
||||
});
|
||||
`)}`;
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [`--import=${hook}`, ...nodeArguments, entrypoint, "mcp"],
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: root,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: configuration.input,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: configuration.output,
|
||||
REA_MCP_TOOL_CATALOG_PROFILE: configuration.catalog,
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const client = new Client(
|
||||
{ name: "deferred-schema-profile-client", version: "1" },
|
||||
{
|
||||
versionNegotiation: { mode: configuration.mode },
|
||||
jsonSchemaValidator: validation,
|
||||
},
|
||||
);
|
||||
let subscription: Awaited<ReturnType<Client["listen"]>> | undefined;
|
||||
const close = () =>
|
||||
closeVerifierResources([
|
||||
() => subscription?.close() ?? Promise.resolve(),
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
]);
|
||||
onTestFinished(close);
|
||||
await client.connect(transport);
|
||||
let definitions = new Map<string, Tool>();
|
||||
const list = async () => {
|
||||
const result = await client.listTools();
|
||||
definitions = new Map(result.tools.map((tool) => [tool.name, tool]));
|
||||
return result;
|
||||
};
|
||||
const definition = (name: string) => {
|
||||
const found = definitions.get(name);
|
||||
if (found === undefined) throw new Error(`Tool not advertised: ${name}`);
|
||||
return found;
|
||||
};
|
||||
const call = (
|
||||
name: string,
|
||||
arguments_: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{
|
||||
toolDefinition: definition(name),
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const changed = vi.fn();
|
||||
client.setNotificationHandler("notifications/tools/list_changed", changed);
|
||||
if (configuration.mode === "auto")
|
||||
subscription = await client.listen({ toolsListChanged: true });
|
||||
const load = async (names: string[]) => {
|
||||
if (configuration.catalog === "hot-cold") {
|
||||
const result = await call("discover_tools", { names });
|
||||
expect(result.isError).not.toBe(true);
|
||||
await list();
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
tools: [...new Set(names)].map((name) => ({
|
||||
name,
|
||||
input_schema: definition(name).inputSchema,
|
||||
})),
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
root,
|
||||
client,
|
||||
list,
|
||||
definition,
|
||||
call,
|
||||
load,
|
||||
changed,
|
||||
close,
|
||||
runtimeLoaded: async () => (await readFile(log, "utf8")).length > 0,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
};
|
||||
|
||||
const unwrapGuidance = (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "result" in value
|
||||
? value.result
|
||||
: value;
|
||||
|
||||
const verifyGuidance = async (
|
||||
f: Awaited<ReturnType<typeof connect>>,
|
||||
configuration: Configuration,
|
||||
) => {
|
||||
// The first domain call uses a root union, before any incidental materialization.
|
||||
const found = await f.call("search_guidance", {
|
||||
query: "exact JavaScript module",
|
||||
limit: 1,
|
||||
});
|
||||
expect(found.isError).not.toBe(true);
|
||||
expect(found.structuredContent).toHaveProperty(
|
||||
configuration.mode === "legacy" ? "result.status" : "status",
|
||||
"matches",
|
||||
);
|
||||
expect(
|
||||
searchGuidanceOutputSchema.parse(unwrapGuidance(found.structuredContent))
|
||||
.status,
|
||||
).toBe("matches");
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
|
||||
const missing = await f.call("search_guidance", {
|
||||
query: "unmatched_qzxv",
|
||||
});
|
||||
expect(
|
||||
searchGuidanceOutputSchema.parse(unwrapGuidance(missing.structuredContent))
|
||||
.status,
|
||||
).toBe("no_matches");
|
||||
const badGuidance = await f.call("search_guidance", { query: 17 });
|
||||
expect(parseMcpToolError(badGuidance).error.code).toBe("invalid_request");
|
||||
expect(badGuidance.structuredContent).toBeUndefined();
|
||||
|
||||
const guide = await f.call("search_guidance", {
|
||||
query: "inspect_analysis_view OpenAPI",
|
||||
limit: 1,
|
||||
});
|
||||
const matches = searchGuidanceOutputSchema.parse(
|
||||
unwrapGuidance(guide.structuredContent),
|
||||
);
|
||||
if (matches.status !== "matches")
|
||||
throw new Error("Expected compiled tool guidance");
|
||||
expect(matches.matches[0]?.guide).toBe("tools/inspect_analysis_view.md");
|
||||
const read = await f.call("read_guidance_section", {
|
||||
locator: matches.matches[0]?.locator,
|
||||
});
|
||||
expect(read.isError).not.toBe(true);
|
||||
expect(
|
||||
readGuidanceSectionOutputSchema.parse(
|
||||
unwrapGuidance(read.structuredContent),
|
||||
).status,
|
||||
).toBe("section");
|
||||
};
|
||||
|
||||
it.each(matrix)(
|
||||
"preserves schemas, guidance and retained Evidence over $mode with $input inputs, $output outputs and $catalog catalog",
|
||||
async (configuration) => {
|
||||
const f = await connect(configuration);
|
||||
const initial = await f.list();
|
||||
expect(f.client.getProtocolEra()).toBe(
|
||||
configuration.mode === "legacy" ? "legacy" : "modern",
|
||||
);
|
||||
const eager = configuration.output !== "full";
|
||||
expect(await f.runtimeLoaded()).toBe(eager);
|
||||
if (configuration.catalog === "hot-cold") {
|
||||
expect(new Set(initial.tools.map(({ name }) => name))).toEqual(
|
||||
new Set([...HOT_TOOL_NAMES, "discover_tools"]),
|
||||
);
|
||||
} else {
|
||||
expect(initial.tools.map(({ name }) => name)).toContain(
|
||||
"inspect_analysis_view",
|
||||
);
|
||||
}
|
||||
await f.load([
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
"inspect_api_contract",
|
||||
"inspect_analysis_view",
|
||||
]);
|
||||
expect(await f.runtimeLoaded()).toBe(eager);
|
||||
const before = await f.list();
|
||||
for (const name of [
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
"inspect_api_contract",
|
||||
"inspect_analysis_view",
|
||||
]) {
|
||||
const definition = f.definition(name);
|
||||
expect(
|
||||
validation.assertions.validateSchema(definition.inputSchema),
|
||||
name,
|
||||
).toBe(true);
|
||||
if (configuration.output === "off")
|
||||
expect(definition.outputSchema, name).toBeUndefined();
|
||||
else {
|
||||
if (definition.outputSchema === undefined)
|
||||
throw new Error(`Missing output schema: ${name}`);
|
||||
expect(
|
||||
validation.assertions.validateSchema(definition.outputSchema),
|
||||
name,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
await verifyGuidance(f, configuration);
|
||||
expect(await f.list()).toEqual(before);
|
||||
|
||||
const path = join(f.root, "api.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Schema matrix", version: "1" },
|
||||
paths: {
|
||||
"/items": { get: { operationId: "listItems" } },
|
||||
"/items/{id}": { delete: { operationId: "removeItem" } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const produced = await f.call("inspect_api_contract", {
|
||||
path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(produced.isError).not.toBe(true);
|
||||
const parent = parseEvidence(produced.structuredContent);
|
||||
expect(
|
||||
apiContractSchema.parse(parent.normalized_result).operations,
|
||||
).toMatchObject([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/items",
|
||||
declared_id: "listItems",
|
||||
source_pointer: "/paths/~1items/get",
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/items/{id}",
|
||||
declared_id: "removeItem",
|
||||
source_pointer: "/paths/~1items~1{id}/delete",
|
||||
},
|
||||
]);
|
||||
await rm(path);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
const view = {
|
||||
kind: "api-contract",
|
||||
collection: "operations",
|
||||
paths: ["/items"],
|
||||
fields: ["method", "path"],
|
||||
limit: 1,
|
||||
};
|
||||
const malformed = { source, view: { ...view, offset: -1 } };
|
||||
expect(
|
||||
toolContract("inspect_analysis_view").inputSchema.safeParse(malformed)
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validation.assertions.validate(
|
||||
f.definition("inspect_analysis_view").inputSchema,
|
||||
{ source, view },
|
||||
),
|
||||
).toBe(true);
|
||||
// Compact advertisements may safely retain more canonical constraints.
|
||||
// Exact rejection belongs to the full projection and the server parser.
|
||||
if (configuration.input === "full")
|
||||
expect(
|
||||
validation.assertions.validate(
|
||||
f.definition("inspect_analysis_view").inputSchema,
|
||||
malformed,
|
||||
),
|
||||
).toBe(false);
|
||||
const rejected = await f.call("inspect_analysis_view", malformed);
|
||||
expect(parseMcpToolError(rejected).error.code).toBe("invalid_request");
|
||||
expect(rejected.structuredContent).toBeUndefined();
|
||||
const selected = await f.call("inspect_analysis_view", { source, view });
|
||||
expect(selected.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(selected.structuredContent);
|
||||
expect(
|
||||
analysisViewResultSchema.parse(evidence.normalized_result),
|
||||
).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: [{ method: "GET", path: "/items", state: "declared" }],
|
||||
});
|
||||
expect(evidence.evidence_links).toEqual([parent.evidence_id]);
|
||||
const repeated = await f.call("inspect_analysis_view", { source, view });
|
||||
expect(parseEvidence(repeated.structuredContent)).toEqual(evidence);
|
||||
const unknown = await f.call("inspect_analysis_view", {
|
||||
source: { ...source, evidence_id: `ev_${"b".repeat(64)}` },
|
||||
view,
|
||||
});
|
||||
expect(parseMcpToolError(unknown).error.code).toBe(
|
||||
"evidence_integrity_mismatch",
|
||||
);
|
||||
expect(unknown.structuredContent).toBeUndefined();
|
||||
const notifications = f.changed.mock.calls.length;
|
||||
await f.load([
|
||||
"inspect_analysis_view",
|
||||
"search_guidance",
|
||||
"inspect_analysis_view",
|
||||
]);
|
||||
expect(f.changed).toHaveBeenCalledTimes(notifications);
|
||||
expect(notifications).toBe(configuration.catalog === "hot-cold" ? 1 : 0);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
const verifyReconnect = async ({
|
||||
configuration,
|
||||
names,
|
||||
parent,
|
||||
view,
|
||||
}: {
|
||||
configuration: Configuration;
|
||||
names: string[];
|
||||
parent: ReturnType<typeof parseEvidence>;
|
||||
view: Record<string, unknown>;
|
||||
}) => {
|
||||
const source = { kind: "retained-evidence", evidence_id: parent.evidence_id };
|
||||
const next = await connect(configuration);
|
||||
const fresh = await next.list();
|
||||
expect(fresh.tools.map(({ name }) => name)).toEqual(
|
||||
configuration.catalog === "hot-cold"
|
||||
? names.filter(
|
||||
(name) => HOT_TOOL_NAMES.has(name) || name === "discover_tools",
|
||||
)
|
||||
: names,
|
||||
);
|
||||
expect(await next.runtimeLoaded()).toBe(false);
|
||||
await next.load(["inspect_analysis_view"]);
|
||||
const expired = await next.call("inspect_analysis_view", {
|
||||
source,
|
||||
view,
|
||||
});
|
||||
expect(parseMcpToolError(expired).error.code).toBe(
|
||||
"evidence_integrity_mismatch",
|
||||
);
|
||||
const portable = await next.call("inspect_analysis_view", {
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view,
|
||||
});
|
||||
expect(portable.isError).not.toBe(true);
|
||||
expect(
|
||||
analysisViewResultSchema.parse(
|
||||
parseEvidence(portable.structuredContent).normalized_result,
|
||||
),
|
||||
).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: [{ method: "GET", path: "/peer" }],
|
||||
});
|
||||
expect(next.stderr()).toBe("");
|
||||
};
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"keeps a peer callable after cancellation during $mode/$catalog materialization, then reconnects with fresh visibility and Evidence",
|
||||
async (configuration) => {
|
||||
const control = await createTestTempDirectory(
|
||||
"rea-materialization-cancellation-",
|
||||
);
|
||||
const started = join(control, "started");
|
||||
const release = join(control, "release");
|
||||
// Hold the real lazy module at import time. Every request still passes
|
||||
// through the production SDK transport, registration and canonical parser.
|
||||
const gate = `await (async () => {
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(${JSON.stringify(started)}, "started");
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
try { await fs.access(${JSON.stringify(release)}); return; } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error("Fixture materialization was not released");
|
||||
})();\n`;
|
||||
const hook = `data:text/javascript,${encodeURIComponent(`
|
||||
import { registerHooks } from "node:module";
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const loaded = next(url, context);
|
||||
if (url.endsWith("/dist/main/eagerRuntime.js") ||
|
||||
loaded.source?.toString().includes("//#region dist/main/eagerRuntime.js\\n")) {
|
||||
return { ...loaded, source: ${JSON.stringify(gate)} + loaded.source.toString() };
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
});
|
||||
`)}`;
|
||||
const f = await connect(configuration, [`--import=${hook}`]);
|
||||
await f.list();
|
||||
await f.load([
|
||||
"inspect_api_contract",
|
||||
"inspect_analysis_view",
|
||||
"export_evidence_bundle",
|
||||
]);
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const before = await f.list();
|
||||
const path = join(control, "peer.json");
|
||||
const cancelledPath = join(control, "cancelled.json");
|
||||
const api = (id: string) =>
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Cancellation fixture", version: "1" },
|
||||
paths: { "/peer": { get: { operationId: id } } },
|
||||
});
|
||||
await writeFile(path, api("retainedPeer"));
|
||||
await writeFile(cancelledPath, api("cancelledMustNotRun"));
|
||||
const controller = new AbortController();
|
||||
const reason = new SdkError(
|
||||
SdkErrorCode.RequestTimeout,
|
||||
"Cancelled during shared deferred initialization",
|
||||
);
|
||||
const cancelled = f.call(
|
||||
"inspect_api_contract",
|
||||
{ path: cancelledPath, detail: "complete" },
|
||||
controller.signal,
|
||||
);
|
||||
const rejected = expect(cancelled).rejects.toBe(reason);
|
||||
const pending: Promise<unknown>[] = [cancelled, rejected];
|
||||
try {
|
||||
const acknowledged = vi.waitFor(
|
||||
async () => expect(await readFile(started, "utf8")).toBe("started"),
|
||||
{ timeout: 2000, interval: 10 },
|
||||
);
|
||||
pending.push(acknowledged);
|
||||
await awaitAcknowledgement(
|
||||
acknowledged,
|
||||
cancelled,
|
||||
"public deferred materialization",
|
||||
);
|
||||
const peer = f.call("inspect_api_contract", { path, detail: "complete" });
|
||||
pending.push(peer);
|
||||
controller.abort(reason);
|
||||
await rejected;
|
||||
// A round trip while import is held also proves the catalog/protocol
|
||||
// remain responsive after sending the cancellation notification.
|
||||
expect(await f.list()).toEqual(before);
|
||||
await writeFile(release, "release");
|
||||
const result = await peer;
|
||||
expect(result.isError).not.toBe(true);
|
||||
const parent = parseEvidence(result.structuredContent);
|
||||
expect(
|
||||
apiContractSchema.parse(parent.normalized_result).operations,
|
||||
).toMatchObject([
|
||||
{ path: "/peer", method: "GET", declared_id: "retainedPeer" },
|
||||
]);
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
expect(await f.list()).toEqual(before);
|
||||
const exportPath = join(control, "bundle.json");
|
||||
expect(
|
||||
(await f.call("export_evidence_bundle", { path: exportPath })).isError,
|
||||
).not.toBe(true);
|
||||
const bundle = evidenceBundleSchema.parse(
|
||||
JSON.parse(await readFile(exportPath, "utf8")),
|
||||
);
|
||||
expect(
|
||||
bundle.records
|
||||
.filter(({ operation }) => operation === "inspect_api_contract")
|
||||
.map(({ evidence_id }) => evidence_id),
|
||||
).toEqual([parent.evidence_id]);
|
||||
await rm(path);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
const view = {
|
||||
kind: "api-contract",
|
||||
collection: "operations",
|
||||
fields: ["method", "path"],
|
||||
limit: 1,
|
||||
};
|
||||
const selected = await f.call("inspect_analysis_view", { source, view });
|
||||
expect(selected.isError).not.toBe(true);
|
||||
expect(
|
||||
analysisViewResultSchema.parse(
|
||||
parseEvidence(selected.structuredContent).normalized_result,
|
||||
),
|
||||
).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: [{ method: "GET", path: "/peer" }],
|
||||
});
|
||||
await f.close();
|
||||
|
||||
await verifyReconnect({
|
||||
configuration,
|
||||
names: before.tools.map(({ name }) => name),
|
||||
parent,
|
||||
view,
|
||||
});
|
||||
expect(f.stderr()).toBe("");
|
||||
} finally {
|
||||
controller.abort(reason);
|
||||
await writeFile(release, "release");
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"recovers from a malformed first domain call over $mode/$catalog without replacing advertised schemas",
|
||||
async (configuration) => {
|
||||
const f = await connect(configuration);
|
||||
await f.list();
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
const before = await f.list();
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const malformed = await f.call("search_guidance", { query: 17 });
|
||||
expect(parseMcpToolError(malformed).error.code).toBe("invalid_request");
|
||||
expect(malformed.structuredContent).toBeUndefined();
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
await verifyGuidance(f, configuration);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"keeps discovery responsive and rejects domain calls after $mode/$catalog materialization fails",
|
||||
async (configuration) => {
|
||||
// Fault the lazy module's evaluation, preserving its real imports/exports.
|
||||
// Transport startup and discovery must still use the production server.
|
||||
const hook = `data:text/javascript,${encodeURIComponent(`
|
||||
import { registerHooks } from "node:module";
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const loaded = next(url, context);
|
||||
if (url.endsWith("/dist/main/eagerRuntime.js") ||
|
||||
loaded.source?.toString().includes("//#region dist/main/eagerRuntime.js\\n"))
|
||||
return { ...loaded, source: 'throw new Error("PRIVATE fixture initialization failure");\\n' + loaded.source.toString() };
|
||||
return loaded;
|
||||
}
|
||||
});
|
||||
`)}`;
|
||||
const f = await connect(configuration, [`--import=${hook}`]);
|
||||
await f.list();
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
const before = await f.list();
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
for (const query of ["inspect_analysis_view OpenAPI", "unmatched_qzxv"]) {
|
||||
const result = await f.call("search_guidance", { query });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toBeUndefined();
|
||||
expect(JSON.stringify(result)).toContain(SERVER_START_FAILED);
|
||||
expect(JSON.stringify(result)).not.toContain("PRIVATE");
|
||||
}
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"materializes through a prompt operation over $mode/$catalog and preserves schemas and live Evidence completion",
|
||||
async (configuration) => {
|
||||
const f = await connect(configuration);
|
||||
await f.list();
|
||||
await f.load([
|
||||
"inspect_api_contract",
|
||||
"export_evidence_bundle",
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
]);
|
||||
const toolsBefore = await f.list();
|
||||
const promptsBefore = await f.client.listPrompts();
|
||||
expect(promptsBefore.prompts.map(({ name }) => name)).toContain(
|
||||
"verify_reconstruction",
|
||||
);
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const complete = () =>
|
||||
f.client.complete({
|
||||
ref: { type: "ref/prompt", name: "verify_reconstruction" },
|
||||
argument: { name: "comparison_evidence_id", value: "ev_" },
|
||||
});
|
||||
// Use retrieval first on legacy, completion first on modern; neither
|
||||
// operation should execute an analysis or create Evidence observations.
|
||||
if (configuration.mode === "auto")
|
||||
expect((await complete()).completion.values).toEqual([]);
|
||||
const prompt = await f.client.getPrompt({
|
||||
name: "investigate_feature",
|
||||
arguments: { feature: "declared GET /items" },
|
||||
});
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
expect(prompt.messages).toHaveLength(1);
|
||||
expect(prompt.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: expect.stringContaining('"feature":"declared GET /items"'),
|
||||
},
|
||||
});
|
||||
if (configuration.mode === "legacy")
|
||||
expect((await complete()).completion.values).toEqual([]);
|
||||
await expect(
|
||||
f.client.getPrompt({
|
||||
name: "investigate_feature",
|
||||
arguments: { feature: " " },
|
||||
}),
|
||||
).rejects.toThrow(/Invalid arguments/u);
|
||||
expect(await f.client.listPrompts()).toEqual(promptsBefore);
|
||||
expect(await f.list()).toEqual(toolsBefore);
|
||||
const exportPath = join(f.root, "prompt-only.json");
|
||||
expect(
|
||||
(await f.call("export_evidence_bundle", { path: exportPath })).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
evidenceBundleSchema.parse(JSON.parse(await readFile(exportPath, "utf8")))
|
||||
.records,
|
||||
).toEqual([]);
|
||||
const path = join(f.root, "completion.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Prompt completion", version: "1" },
|
||||
paths: { "/items": { get: { operationId: "listItems" } } },
|
||||
}),
|
||||
);
|
||||
const result = await f.call("inspect_api_contract", {
|
||||
path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(result.structuredContent);
|
||||
expect(
|
||||
apiContractSchema.parse(evidence.normalized_result).operations,
|
||||
).toMatchObject([{ method: "GET", path: "/items" }]);
|
||||
expect((await complete()).completion.values).toEqual([
|
||||
evidence.evidence_id,
|
||||
]);
|
||||
await verifyGuidance(f, configuration);
|
||||
expect(await f.list()).toEqual(toolsBefore);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"validates stale and unavailable guidance replies and recovers after bundle repair over $mode/$catalog",
|
||||
async (configuration) => {
|
||||
const runtime = await createTestTempDirectory(
|
||||
"rea-guidance-failure-runtime-",
|
||||
);
|
||||
// Exercise the shipped dispatcher and runtime with an independently
|
||||
// editable guide bundle. Only locked dependencies are linked to the build.
|
||||
for (const path of ["dist", "skills", "scripts", "package.json"])
|
||||
await cp(resolve(path), join(runtime, path), { recursive: true });
|
||||
await symlink(
|
||||
resolve("node_modules"),
|
||||
join(runtime, "node_modules"),
|
||||
"junction",
|
||||
);
|
||||
const guidePath = join(
|
||||
runtime,
|
||||
"skills/reverse-engineer-anything/SKILL.md",
|
||||
);
|
||||
const original = await readFile(guidePath);
|
||||
const f = await connect(
|
||||
configuration,
|
||||
[],
|
||||
join(runtime, "scripts/rea.mjs"),
|
||||
);
|
||||
await f.list();
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
const before = await f.list();
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const search = async (query: string, cursor?: string) => {
|
||||
const result = await f.call("search_guidance", {
|
||||
query,
|
||||
limit: 1,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
return searchGuidanceOutputSchema.parse(
|
||||
unwrapGuidance(result.structuredContent),
|
||||
);
|
||||
};
|
||||
const found = await search("inspect_analysis_view OpenAPI");
|
||||
if (found.status !== "matches" || found.matches[0] === undefined)
|
||||
throw new Error("Expected a compiled guidance locator");
|
||||
expect(found.matches[0].guide).toBe("tools/inspect_analysis_view.md");
|
||||
const locator = found.matches[0].locator;
|
||||
const page = await search("inspect_analysis_view");
|
||||
if (page.status !== "matches" || page.next_cursor === undefined)
|
||||
throw new Error("Expected a guidance continuation token");
|
||||
const read = async () => {
|
||||
const result = await f.call("read_guidance_section", { locator });
|
||||
expect(result.isError).not.toBe(true);
|
||||
return readGuidanceSectionOutputSchema.parse(
|
||||
unwrapGuidance(result.structuredContent),
|
||||
);
|
||||
};
|
||||
await writeFile(
|
||||
guidePath,
|
||||
Buffer.concat([
|
||||
original,
|
||||
Buffer.from("\n<!-- bundle drift fixture -->\n"),
|
||||
]),
|
||||
);
|
||||
expect((await read()).status).toBe("stale_locator");
|
||||
expect(
|
||||
(await search("inspect_analysis_view", page.next_cursor)).status,
|
||||
).toBe("stale_continuation");
|
||||
const invalid = await f.call("read_guidance_section", {
|
||||
locator: "../../outside.md",
|
||||
});
|
||||
expect(parseMcpToolError(invalid).error.code).toBe("invalid_request");
|
||||
expect(invalid.structuredContent).toBeUndefined();
|
||||
await rm(guidePath);
|
||||
expect((await search("inspect_analysis_view OpenAPI")).status).toBe(
|
||||
"bundle_unavailable",
|
||||
);
|
||||
expect((await read()).status).toBe("bundle_unavailable");
|
||||
await writeFile(guidePath, original);
|
||||
expect(await search("inspect_analysis_view OpenAPI")).toMatchObject({
|
||||
status: "matches",
|
||||
bundle_sha256: found.bundle_sha256,
|
||||
});
|
||||
expect(await read()).toMatchObject({
|
||||
status: "section",
|
||||
guide: "tools/inspect_analysis_view.md",
|
||||
locator,
|
||||
content: expect.stringContaining("api-contract"),
|
||||
});
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
@@ -12,14 +12,25 @@ import {
|
||||
} from "../../support/mcp/streamableHttpProcess.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
|
||||
import { searchGuidanceOutputSchema } from "../../../src/contracts/guidanceContracts.js";
|
||||
|
||||
const HTTP_BOUNDARY_TIMEOUT_MS = 90_000;
|
||||
|
||||
const openSession = async (url: URL, name: string) => {
|
||||
const openSession = async (
|
||||
url: URL,
|
||||
name: string,
|
||||
mode: "legacy" | "auto" = "legacy",
|
||||
) => {
|
||||
const transport = new StreamableHTTPClientTransport(url);
|
||||
const client = new Client({ name, version: "1" });
|
||||
const client = new Client(
|
||||
{ name, version: "1" },
|
||||
{ versionNegotiation: { mode } },
|
||||
);
|
||||
onTestFinished(() => client.close());
|
||||
await client.connect(transport);
|
||||
// HTTP deliberately composes the legacy server; auto must fall back before
|
||||
// using its session lifecycle and unsolicited catalog-change notifications.
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe("2025-11-25");
|
||||
expect(transport.sessionId).toEqual(expect.any(String));
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
@@ -46,57 +57,99 @@ const expectIdentity = async ({
|
||||
});
|
||||
};
|
||||
|
||||
describe("rea mcp --http", () => {
|
||||
processTest(
|
||||
"keeps hot/cold visibility within its HTTP session and refreshes named contracts",
|
||||
async ({ processes }) => {
|
||||
const started = await startListeningProcess(
|
||||
processes,
|
||||
["mcp", "--http", "--port", "0"],
|
||||
{ REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
|
||||
);
|
||||
const first = await openSession(started.url, "hot-cold-first");
|
||||
const initialNames = (await first.client.listTools()).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
expect(initialNames).not.toContain("inspect_analysis_view");
|
||||
expect(initialNames).not.toContain("list_unknowns");
|
||||
let changed = 0;
|
||||
first.client.setNotificationHandler(
|
||||
"notifications/tools/list_changed",
|
||||
() => {
|
||||
changed++;
|
||||
},
|
||||
);
|
||||
const discovered = await first.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["list_unknowns"] },
|
||||
});
|
||||
expect(discovered.isError).not.toBe(true);
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
first.client,
|
||||
"list_unknowns",
|
||||
);
|
||||
expect(changed).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await first.client.callTool(
|
||||
{ name: "list_unknowns", arguments: {} },
|
||||
{ toolDefinition },
|
||||
)
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
await first.transport.terminateSession();
|
||||
await first.client.close();
|
||||
const second = await openSession(started.url, "hot-cold-second");
|
||||
expect(
|
||||
(await second.client.listTools()).tools.map(({ name }) => name),
|
||||
).toEqual(initialNames);
|
||||
await expectIdentity(second);
|
||||
await second.transport.terminateSession();
|
||||
},
|
||||
HTTP_BOUNDARY_TIMEOUT_MS,
|
||||
/** Use the exact refreshed definition; root-union framing belongs to the SDK. */
|
||||
const searchGuidance = async (client: Client, query: string) => {
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"search_guidance",
|
||||
);
|
||||
const result = await client.callTool(
|
||||
{ name: "search_guidance", arguments: { query, limit: 1 } },
|
||||
{ toolDefinition },
|
||||
);
|
||||
expect(result.isError).not.toBe(true);
|
||||
const content = result.structuredContent;
|
||||
return searchGuidanceOutputSchema.parse(
|
||||
typeof content === "object" && content !== null && "result" in content
|
||||
? content.result
|
||||
: content,
|
||||
);
|
||||
};
|
||||
|
||||
describe("rea mcp --http", () => {
|
||||
for (const mode of ["legacy", "auto"] as const)
|
||||
processTest(
|
||||
`${mode} keeps hot/cold visibility within its HTTP session and refreshes named contracts`,
|
||||
async ({ processes }) => {
|
||||
const started = await startListeningProcess(
|
||||
processes,
|
||||
["mcp", "--http", "--port", "0"],
|
||||
{ REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
|
||||
);
|
||||
const first = await openSession(started.url, "hot-cold-first", mode);
|
||||
const initialNames = (await first.client.listTools()).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
expect(initialNames).not.toContain("inspect_analysis_view");
|
||||
expect(initialNames).not.toContain("list_unknowns");
|
||||
let changed = 0;
|
||||
first.client.setNotificationHandler(
|
||||
"notifications/tools/list_changed",
|
||||
() => {
|
||||
changed++;
|
||||
},
|
||||
);
|
||||
const discovered = await first.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["list_unknowns", "search_guidance"] },
|
||||
});
|
||||
expect(discovered.isError).not.toBe(true);
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
first.client,
|
||||
"list_unknowns",
|
||||
);
|
||||
expect(changed).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await first.client.callTool(
|
||||
{ name: "list_unknowns", arguments: {} },
|
||||
{ toolDefinition },
|
||||
)
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
await searchGuidance(first.client, "inspect_analysis_view OpenAPI"),
|
||||
).toMatchObject({
|
||||
status: "matches",
|
||||
matches: [{ guide: "tools/inspect_analysis_view.md" }],
|
||||
});
|
||||
await first.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["search_guidance", "list_unknowns"] },
|
||||
});
|
||||
expect(changed).toBe(1);
|
||||
await first.transport.terminateSession();
|
||||
await first.client.close();
|
||||
const second = await openSession(started.url, "hot-cold-second", mode);
|
||||
expect(
|
||||
(await second.client.listTools()).tools.map(({ name }) => name),
|
||||
).toEqual(initialNames);
|
||||
expect(
|
||||
(
|
||||
await second.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["search_guidance"] },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
(await searchGuidance(second.client, "unmatched_qzxv")).status,
|
||||
).toBe("no_matches");
|
||||
await expectIdentity(second);
|
||||
await second.transport.terminateSession();
|
||||
},
|
||||
HTTP_BOUNDARY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
processTest(
|
||||
"serves initialize, tools/list, a tool call and DELETE, then keeps serving later clients",
|
||||
|
||||
Reference in new issue
Block a user