Files
rea/tests/boundary/mcp/javascriptAnalysisResources.test.ts
T
morluto f3ed1657f8 feat(mcp): support runtime analysis limit recovery (#7049)
* feat(mcp): support runtime analysis limit recovery

* refactor(config): remove unused limit re-exports

* fix(android): keep fixture release metadata dependency-free

* fix(settings): preserve provider budgets and recovery facts

* fix(tests): resolve lint plugins from their repository owner

* refactor(mcp): verify guidance delivery without frozen wording

* fix(android): align availability with runtime timeout

* test(recovery): assert limit facts instead of frozen wording

* fix(mcp): share runtime settings with the default JADX provider

* test(mcp): bound acknowledgements and release held work on failure

* fix(catalog): declare runtime settings tools as MCP-only
2026-10-25 09:45:26 +08:00

395 lines
13 KiB
TypeScript

import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { RECOVERY_INSTRUCTIONS } from "../../../src/server/recoveryInstructions.js";
import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "../../../src/server/toolResultEncoding.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { retainedEvidenceReferenceSchema } from "../../../src/domain/evidenceReference.js";
it("delivers deadline recovery guidance at initialization before any tool call", async () => {
const { client, transport } = createResourceClient();
try {
await client.connect(transport);
// Verify delivery from the owning contracts, not a second copy of their prose.
// Recovery behavior is exercised below and in analysisSettingsMcp.test.ts;
// these assertions do not claim that an agent follows the guidance.
const instructions = client.getInstructions();
expect(instructions).toContain(RECOVERY_INSTRUCTIONS);
expect(instructions).toContain(UNTRUSTED_CONTENT_INSTRUCTIONS);
const catalog = await client.listTools();
const analysis = catalog.tools.find(
(tool) => tool.name === "analyze_javascript_application",
);
expect(analysis?.description).toBe(
toolContract("analyze_javascript_application").description,
);
} finally {
await client.close();
await transport.close();
}
});
it("accepts more work after application expansion exceeds memory and retains every static finding", async () => {
const root = await createTestTempDirectory("rea-js-resource-boundary-");
const nextRoot = await createTestTempDirectory("rea-js-resource-recovery-");
await writeFile(join(nextRoot, "main.js"), "export const recovered = 9;\n");
const source = Array.from(
{ length: 4000 },
(_, index) =>
`import { observed as binding${String(index)} } from "./target.js";`,
).join("\n");
const path = join(root, "main.js");
await writeFile(path, source);
await writeFile(join(root, "target.js"), "export const observed = 7;\n");
const digest = createHash("sha256").update(source).digest("hex");
const { client, transport, diagnostics } = createResourceClient();
try {
await client.connect(transport);
const response = await client.callTool(
{
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
max_heap_mb: 128,
detail: "summary",
},
},
{ timeout: 60000 },
);
expect(response.isError).not.toBe(true);
const summary = z
.object({
normalized_result: z.object({
parent_evidence_id: z.string(),
summary: z.object({
coverage: z.object({
application: z.object({ status: z.string() }),
}),
}),
}),
})
.parse(response.structuredContent).normalized_result;
expect(summary.summary.coverage.application.status).toBe("partial");
await verifyRecovery(client, nextRoot, diagnostics);
const retained = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const pageResponse = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: retained,
view: { kind: "page", collection: "modules", offset: 0, limit: 100 },
},
});
expect(pageResponse.isError).not.toBe(true);
const page = z
.object({
normalized_result: z.object({
items: z.array(
z.object({
node_id: z.string(),
kind: z.string(),
path: z.string().nullable(),
}),
),
}),
})
.parse(pageResponse.structuredContent).normalized_result;
const module = page.items.find(
(item) => item.kind === "javascript-asset" && item.path === "main.js",
);
if (module === undefined)
throw new Error("The analyzed file disappeared from public module views");
const itemResponse = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: retained,
view: {
kind: "item",
collection: "modules",
selector: { node_id: module.node_id },
},
},
});
expect(itemResponse.isError).not.toBe(true);
const item = z
.object({
normalized_result: z.object({
item: z.object({
identity: z.object({ sha256: z.string() }),
observations: z.array(
z.object({
properties: z.object({
static_analysis: z
.object({
parse_status: z.string(),
references: z.array(
z.object({
specifier: z.string(),
location: z.object({
start: z.object({ line: z.number() }),
}),
}),
),
})
.optional(),
}),
}),
),
}),
}),
})
.parse(itemResponse.structuredContent).normalized_result.item;
expect(item.identity.sha256).toBe(digest);
const facts = item.observations.find(
(observation) => observation.properties.static_analysis !== undefined,
)?.properties.static_analysis;
if (facts === undefined)
throw new Error("Completed static observations were discarded");
expect(facts.parse_status).toBe("complete");
expect(
facts.references.map((reference) => [
reference.specifier,
reference.location.start.line,
]),
).toEqual(
Array.from({ length: 4000 }, (_, index) => ["./target.js", index + 1]),
);
expect(
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
).toBe(digest);
expect(diagnostics()).not.toMatch(/FATAL ERROR:.*heap/iu);
} finally {
await client.close();
await transport.close();
}
}, 90000);
const repeatedSourceCount = 1500;
const repeatedSourceText = "export const observed = 7;\n";
it("returns a directly usable partial analysis reference for an oversized timeout observation", async () => {
const root = await createTestTempDirectory("rea-js-large-timeout-");
for (let offset = 0; offset < repeatedSourceCount; offset += 50)
await Promise.all(
Array.from({ length: 50 }, (_, index) =>
writeFile(
join(root, `file-${String(offset + index)}.js`),
repeatedSourceText,
),
),
);
const nextRoot = await createTestTempDirectory("rea-js-timeout-recovery-");
await writeFile(join(nextRoot, "main.js"), "export const recovered = 9;\n");
const { client, transport, diagnostics } = createResourceClient();
try {
await client.connect(transport);
const response = await client.callTool(
{
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
max_heap_mb: 128,
analysis_timeout_ms: 1,
},
},
{ timeout: 60000 },
);
const { error } = parseMcpToolError(response);
expect(error.code).toBe("provider_timeout");
const partial = retainedEvidenceReferenceSchema.parse(
error.details?.partial_observation,
);
expect(error.details).toMatchObject({
limit: {
setting: "javascript.analysis_timeout_ms",
effective_value: 1,
parameter: "analysis_timeout_ms",
unit: "ms",
minimum: 1,
maximum: 2_147_483_647,
applies_at: "next_operation",
agent_editable: true,
rea_restart_required: false,
},
});
expect(error.recovery).toEqual({
kind: "inspect_timeout_state",
operation: "analyze_javascript_application",
timeout_ms: 1,
});
// Check worker recovery before materializing retained views: those views
// independently consume parent capacity. Then prove the old Evidence is
// still usable after the server has completed a different analysis.
await verifyRecovery(client, nextRoot, diagnostics);
const summaryResponse = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: partial,
view: { kind: "summary" },
},
});
expect(summaryResponse.isError).not.toBe(true);
const summary = z
.object({
normalized_result: z.object({
summary: z.object({
statistics: z.object({
relevant_files: z.number(),
parsed_javascript_files: z.number(),
parse_failures: z.number(),
}),
coverage: z.object({ semantic_unknowns: z.number() }),
}),
}),
})
.parse(summaryResponse.structuredContent).normalized_result.summary;
expect(summary.statistics).toEqual({
relevant_files: repeatedSourceCount,
parsed_javascript_files: 0,
parse_failures: 0,
});
expect(summary.coverage.semantic_unknowns).toBeGreaterThan(
repeatedSourceCount,
);
await verifyRepeatedSourcePaths(client, partial);
expect(diagnostics()).not.toMatch(/FATAL ERROR:.*heap/iu);
} finally {
await client.close();
await transport.close();
}
}, 90000);
const verifyRecovery = async (
client: Client,
nextRoot: string,
diagnostics: () => string,
): Promise<void> => {
await client.ping();
const next = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: nextRoot,
format: "directory",
max_heap_mb: 128,
detail: "summary",
},
});
expect(
next.isError,
JSON.stringify({ next, diagnostics: diagnostics() }),
).not.toBe(true);
expect(next.structuredContent).toMatchObject({
normalized_result: {
summary: {
input_path: nextRoot,
statistics: {
relevant_files: 1,
parsed_javascript_files: 1,
parse_failures: 0,
},
},
},
});
await client.ping();
};
const createResourceClient = () => {
const client = new Client({ name: "javascript-resources", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: {
PATH: process.env.PATH ?? "",
NODE_OPTIONS: "--max-old-space-size=512 --max-semi-space-size=8",
},
stderr: "pipe",
});
let diagnostics = "";
transport.stderr?.on("data", (chunk: Buffer) => {
if (diagnostics.length < 262144) diagnostics += chunk.toString();
});
return { client, transport, diagnostics: () => diagnostics };
};
const verifyRepeatedSourcePaths = async (
client: Client,
source: { readonly kind: "retained-evidence"; readonly evidence_id: string },
): Promise<void> => {
const response = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: {
kind: "javascript-facts",
where: { kinds: ["javascript-asset"] },
property_keys: [],
limit: repeatedSourceCount,
},
},
});
expect(response.isError).not.toBe(true);
// Every occurrence is independently identified and sourced. No module
// properties are needed, and a full item would retain unrelated data in
// the same deliberately heap-constrained server used for recovery.
const digest = createHash("sha256").update(repeatedSourceText).digest("hex");
const facts = z
.object({
normalized_result: z.object({
rows: z.array(
z.object({
node_id: z.string(),
observation_id: z.string(),
identity: z.object({
strategy: z.literal("content-digest"),
sha256: z.literal(digest),
}),
properties: z.strictObject({}),
evidence: z.object({
location: z.object({
available: z.literal(true),
value: z.object({
kind: z.literal("artifact-path"),
path: z.string(),
}),
}),
}),
}),
),
coverage: z.object({
examined: z.literal(repeatedSourceCount),
total: z.literal(repeatedSourceCount),
exhausted: z.literal(true),
next_offset: z.null(),
}),
}),
})
.parse(response.structuredContent).normalized_result;
expect(facts.rows).toHaveLength(repeatedSourceCount);
expect(new Set(facts.rows.map(({ node_id }) => node_id)).size).toBe(1);
expect(
new Set(facts.rows.map(({ observation_id }) => observation_id)).size,
).toBe(repeatedSourceCount);
expect(
facts.rows.map(({ evidence }) => evidence.location.value.path).sort(),
).toEqual(
Array.from(
{ length: repeatedSourceCount },
(_, index) => `file-${String(index)}.js`,
).sort(),
);
};