Files
rea/tests/boundary/mcp/analyzeFunctionDetail.test.ts
Kaoru0822andClaude Opus 5.5 35ae7232bf fix(enhanced): stop call graph traversals at unconfirmed provider cleanup (#8516)
* fix(enhanced): stop call graph traversals at unconfirmed provider cleanup

A relationship read whose provider could not confirm cleanup of resources it
owns was projected into a graph node or call-path failure, the traversal kept
dispatching queued reads, and get_call_graph / trace_call_path returned
success with the cleanup_incomplete status buried inside the result.

Both traversals now read relationships through one shared owner,
readCallRelationships, which classifies a cancelled read or a read with
unconfirmed owned cleanup as terminal. On such a cleanup stop the workflow
returns the provider's own error as the outcome, so its cleanup owner, retry
binding and live resolution stay intact, and keeps the work admitted before
the stop as the workflow's normal result shape with a caveat naming the
stopped procedure and the unexpanded count. The composed-workflow layer
turns that admitted result into partial workflow Evidence on the failure,
and the MCP adapter retains it through the shared partial-error delivery.
Ordinary read failures still produce successful partial results.

Fixes #8369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(enhanced): retain every carried partial Evidence on enhanced failures

Enhanced-tool failures used the shared partial-error delivery only when this
PR's workflow partial was attached. A failing read that already carried its
own partial Evidence, and analyze_function dossier failures, were projected
inline and never recorded with the session writer, so readback and bundle
export could not recover them. Every enhanced execution failure now goes
through one delivery owner that uses toPartialErrorToolResult with the
session writer; it still falls back to inline when no writer or no valid
Evidence exists.

Tests compare the traversal caveat against its owning formatter instead of
copying the sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 08:52:53 -08:00

341 lines
13 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import pino from "pino";
import { expect, it, onTestFinished, vi } from "vitest";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { registerEnhancedTools } from "../../../src/server/registerEnhancedTools.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
import { withAdmittedAnalysis } from "../../../src/server/analysisAdmission.js";
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../../src/domain/evidence.js";
import { join } from "node:path";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { ok } from "../../../src/domain/result.js";
import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import { parseGhidraFunctionInput } from "../../../src/ghidra/GhidraFunctionValues.js";
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
import { registerAnalysisViewTool } from "../../../src/server/registerAnalysisViewTool.js";
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const partialCallees = {
status: "partial" as const,
limitations: ["Callees: fixture read limit"],
limited_directions: ["callees"] as const,
};
// A fixed provider that returns the fixture dossier for any analyze_function
// call, after validating the request exactly as the production Ghidra client does.
const connect = async (
retain = true,
result = dossier,
path = () => "/fixtures/native.exe",
sources: readonly Evidence[] = [],
) => {
const ledger = new EvidenceLedger();
const record = (evidence: Evidence) => ledger.record(evidence);
const lookup = (id: string) => ledger.forAnalysis(id);
const execute = vi.fn(
async (operation: string, parameters: Record<string, JsonValue>) => {
let observed: JsonValue = result;
if (operation === "analyze_function") {
const input = parseGhidraFunctionInput(operation, parameters);
if (!input.ok) return input;
} else if (operation === "list_procedures") {
observed = [{ address: "0x401000", value: "main" }];
} else if (operation === "procedure_info") {
observed = null;
} else if (operation === "procedure_pseudo_code") {
observed = "int main(void) { return 0; }";
} else throw new Error(`Unexpected operation: ${operation}`);
return ok({
result: observed,
rawResult: null,
provider: { id: "ghidra", name: "Ghidra", version: "12.1.4" },
limitations: [],
locations: [],
sourceEvidence: sources,
subject: {
path: path(),
format: "pe" as const,
sha256: "a".repeat(64),
},
relationshipCoverage: partialCallees,
});
},
);
const analysis = { execute } as unknown as AnalysisOperationPort;
const delivery = createToolResultDelivery(4 * 1024 * 1024);
const server = new EvidenceMcpServer(
{ name: "fn-detail", version: "1" },
{},
retain ? record : undefined,
delivery,
);
registerEnhancedTools(server, {
withAdmittedAnalysis: withAdmittedAnalysis({ kind: "fixed", analysis }),
logger: pino({ enabled: false }),
activeTarget: () => undefined,
analysisProfile: undefined,
recordEvidence: retain ? record : undefined,
evidenceById: retain ? lookup : undefined,
recordUnknown: undefined,
allowsSnapshotReplay: undefined,
recordWorkflowSnapshot: undefined,
});
registerAnalysisViewTool(
server,
pino({ enabled: false }),
retain ? lookup : undefined,
retain ? record : undefined,
);
const client = new Client({ name: "fn-detail-client", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return { client, ledger, execute };
};
it.each(["analyze_function", "decompile_all"] as const)(
"retains plain-port source records before delivering %s links",
async (operation) => {
const source = createEvidence(
undefined,
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{
operation: "health",
parameters: { facet: "historical-report" },
result: { origin: "caller-project" },
},
{ immutable: true },
);
const { client, ledger } = await connect(true, dossier, undefined, [
source,
]);
const parent = await createTestTempDirectory("rea-plain-source-");
const reply = await client.callTool({
name: operation,
arguments:
operation === "analyze_function"
? { procedure: "main", detail: "complete" }
: { output_directory: join(parent, "out") },
});
expect(reply.isError).not.toBe(true);
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.evidence_links).toContain(source.evidence_id);
expect(JSON.stringify(reply)).not.toContain("caller-project");
expect(ledger.forAnalysis(source.evidence_id)).toEqual(source);
expect(
ledger
.immutableBundle()
.records.filter((record) => record.evidence_id === source.evidence_id),
).toHaveLength(1);
},
);
it("refuses source links when a plain-port adapter has no retention owner", async () => {
const source = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "health",
parameters: {},
result: "historical",
},
);
const { client } = await connect(false, dossier, undefined, [source]);
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail: "complete" },
});
expect(reply.isError).toBe(true);
expect(JSON.stringify(reply)).toContain("retains provider source Evidence");
});
it("keeps complete and summary delivery on the current observed path despite retained same-ID history", async () => {
let selected = "/old/native.exe";
const { client, ledger } = await connect(true, dossier, () => selected);
const call = async (detail: "complete" | "summary") => {
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail },
});
expect(reply.isError).not.toBe(true);
return parseEvidence(reply.structuredContent);
};
const old = await call("complete");
selected = "/selected/native.exe";
const complete = await call("complete");
expect(complete.evidence_id).toBe(old.evidence_id);
expect(complete.subject?.local_path).toBe(selected);
const summary = await call("summary");
expect(summary.evidence_links).toEqual([complete.evidence_id]);
expect(summary.subject?.local_path).toBe(complete.subject?.local_path);
expect(summary.normalized_result).toMatchObject({
artifact: { path: selected },
});
expect(summary.locations).toContainEqual({
kind: "artifact-path",
path: selected,
});
expect(ledger.get(complete.evidence_id)?.subject?.local_path).toBe(
"/old/native.exe",
);
const historicalReply = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: complete.evidence_id },
view: { kind: "summary" },
},
});
expect(historicalReply.isError).not.toBe(true);
expect(
parseEvidence(historicalReply.structuredContent).subject?.local_path,
).toBe("/old/native.exe");
});
it("defaults to a retained summary and strips delivery detail before the provider", async () => {
const { client, ledger, execute } = await connect();
const progress: number[] = [];
const reply = await client.callTool(
{ name: "analyze_function", arguments: { procedure: "main" } },
{ onprogress: (update) => progress.push(update.progress) },
);
expect(progress.length).toBeGreaterThanOrEqual(2);
progress.forEach((value, index) => {
expect(value).toBeGreaterThan(progress[index - 1] ?? 0);
});
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
kind: "summary",
parent_operation: "analyze_function",
});
const parentId = (
evidence.normalized_result as { parent_evidence_id: string }
).parent_evidence_id;
// The complete dossier is retained in-session so the summary's parent can be paged.
expect(ledger.has(parentId)).toBe(true);
// Delivery detail never crosses into the provider request.
const providerParameters = execute.mock.calls[0]?.[1] ?? {};
expect(providerParameters).not.toHaveProperty("detail");
expect(providerParameters).toMatchObject({ procedure: "main" });
// Partial caller/callee discovery stays with the summary that reports their counts.
expect(evidence.relationship_coverage).toEqual(partialCallees);
expect(ledger.get(parentId)?.relationship_coverage).toEqual(partialCallees);
// The summary envelope locates the function it describes.
expect(evidence.locations).toEqual([
{ kind: "artifact-path", path: "/fixtures/native.exe" },
{ kind: "address", address: dossier.procedure.address },
]);
// Optional records the fixture omits stay unknown rather than reading as empty.
expect(evidence.normalized_result).toMatchObject({
summary: {
counts: { unresolved_calls: null, swift_string_literals: null },
},
});
});
it("keeps value-flow counts unknown in the default summary when the provider records none", async () => {
// IDA and Kuna dossiers carry native_value_flow: null.
const { client } = await connect(true, {
...dossier,
native_value_flow: null,
});
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main" },
});
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
kind: "summary",
summary: {
value_flow: { available: false, truncated: false },
counts: {
value_flow_operations: null,
value_flow_def_use: null,
value_flow_effects: null,
value_flow_parameters: null,
value_flow_parameter_uses: null,
},
},
unknowns: expect.arrayContaining(["native_value_flow was not recorded"]),
});
});
it("composes inspect_native_api from a provider input without delivery detail", async () => {
const { client, execute } = await connect();
const reply = await client.callTool({
name: "inspect_native_api",
arguments: { procedure: "main" },
});
expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true);
expect(
parseEvidence(reply.structuredContent).normalized_result,
).toMatchObject({ procedure: { name: dossier.procedure.name } });
expect(execute.mock.calls.map((call) => call[1])).toEqual([
{ procedure: "main" },
]);
});
it("returns the complete dossier inline when detail is complete", async () => {
const { client } = await connect();
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail: "complete" },
});
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
pseudocode: dossier.pseudocode,
});
expect(evidence.normalized_result).toHaveProperty("native_value_flow");
});
it("rejects a summary request before analysis when the server retains no Evidence", async () => {
const { client, execute } = await connect(false);
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main" },
});
expect(reply.isError).toBe(true);
expect(execute).not.toHaveBeenCalled();
});
it.each([true, false])(
"delivers an unreadable dossier's partial Evidence through the session writer (retained=%s)",
async (retain) => {
const { client, ledger } = await connect(retain, {
not: "a dossier",
} as never);
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail: "complete" },
});
expect(reply.isError).toBe(true);
const partial = JSON.parse(
reply.content.find((item) => item.type === "text")?.text ?? "{}",
).error.details.partial_observation;
if (!retain) {
expect(parseEvidence(partial).operation).toBe("analyze_function");
return;
}
expect(partial).toMatchObject({ kind: "retained-evidence" });
expect(ledger.forAnalysis(partial.evidence_id)).toMatchObject({
evidence_id: partial.evidence_id,
operation: "analyze_function",
normalized_result: { not: "a dossier" },
});
},
);