diff --git a/scripts/record-mcp-catalog.mjs b/scripts/record-mcp-catalog.mjs index 9012b50633..c3da9bcfb9 100644 --- a/scripts/record-mcp-catalog.mjs +++ b/scripts/record-mcp-catalog.mjs @@ -15,12 +15,16 @@ const [ { parseConfig }, { silentLogger }, { PROMPT_CONTRACTS }, + { TOOL_CONTRACTS }, + { getEvidenceInputContract }, ] = await Promise.all([ read("server/createServer.js"), read("composition/binary.js"), read("config/parseConfig.js"), read("logger.js"), read("contracts/promptContracts.js"), + read("contracts/toolContracts.js"), + read("domain/evidenceInputContract.js"), ]); const recordCatalog = async (profile) => { // Catalog generation is independent of the host account and user configuration. @@ -71,6 +75,11 @@ const recordCatalog = async (profile) => { instructions: client.getInstructions(), identity: server.catalogIdentity, tools, + evidenceReferenceAlternatives: TOOL_CONTRACTS.filter( + (contract) => + getEvidenceInputContract(contract.inputSchema) + .hasEvidenceReferenceAlternative, + ).map((contract) => contract.name), prompts, }; const encoded = JSON.stringify(catalog); diff --git a/src/contracts/evidenceInputConformance.test.ts b/src/contracts/evidenceInputConformance.test.ts index 0950af031e..9dd1b8c900 100644 --- a/src/contracts/evidenceInputConformance.test.ts +++ b/src/contracts/evidenceInputConformance.test.ts @@ -230,6 +230,34 @@ describe("public Evidence input declarations", () => { expect(declarationIssues(schema)).toEqual([]); }); + it("distinguishes replaceable inline Evidence from reference-only sources", () => { + for (const schema of [ + evidenceInputSchema, + evidenceSourceInputSchema, + analysisViewSourceSchema, + z.array(evidenceInputSchema.optional()), + ]) + expect( + getEvidenceInputContract(schema).hasEvidenceReferenceAlternative, + ).toBe(true); + for (const schema of [ + retainedEvidenceReferenceSchema, + evidenceFileReferenceSchema, + exportEnvironmentBundleInputSchema.shape.source, + z.object({ address: z.string() }), + ]) + expect( + getEvidenceInputContract(schema).hasEvidenceReferenceAlternative, + ).toBe(false); + // Reference guidance must not change the separately owned source parser. + expect( + getEvidenceInputContract(evidenceSourceInputSchema).hasEvidence, + ).toBe(false); + expect(getEvidenceInputContract(analysisViewSourceSchema).hasEvidence).toBe( + false, + ); + }); + it("rejects skipping defaults above Evidence-bearing request unions", () => { const choice = z.union([ z.strictObject({ diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 143757355f..5a8920d4b7 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -32,6 +32,7 @@ import { desktopViewRequestSchema } from "./desktopApplicationView.js"; import { javascriptModuleReferencesRequestSchema } from "./javascriptModuleReferences.js"; import { javascriptFactsViewRequestSchema } from "./javascriptFactsView.js"; import { evidenceSchema } from "../evidence.js"; +import { declareEvidenceReferenceAlternative } from "../evidenceInputContract.js"; import { apiSurfaceViewSchema } from "./webNetworkApiSurfaceView.js"; import { evmViewRequestSchema } from "./evmInterfaceView.js"; import { artifactInventoryViewRequestSchema } from "./artifactInventoryView.js"; @@ -117,14 +118,16 @@ inlineEvidenceInputSchema._zod.processJSONSchema = evidenceSchema._zod.processJSONSchema; /** Unauthenticated inline input, an exact same-session retained reference, or one local Evidence file. */ -export const analysisViewSourceSchema = z.discriminatedUnion("kind", [ - retainedEvidenceReferenceSchema, - z.strictObject({ - kind: z.literal("inline"), - evidence: inlineEvidenceInputSchema, - }), - evidenceFileReferenceSchema, -]); +export const analysisViewSourceSchema = declareEvidenceReferenceAlternative( + z.discriminatedUnion("kind", [ + retainedEvidenceReferenceSchema, + z.strictObject({ + kind: z.literal("inline"), + evidence: inlineEvidenceInputSchema, + }), + evidenceFileReferenceSchema, + ]), +); /** Public inspect_analysis_view input. */ export const inspectAnalysisViewInputSchema = z.strictObject({ diff --git a/src/domain/evidenceInputContract.ts b/src/domain/evidenceInputContract.ts index bbd6fc67d1..19440f23ce 100644 --- a/src/domain/evidenceInputContract.ts +++ b/src/domain/evidenceInputContract.ts @@ -11,6 +11,7 @@ type EvidenceInputDeclaration = // Zod registries carry owner metadata through describe/meta clones. This is a // schema declaration registry, never a store of caller Evidence or references. const declarations = z.registry(); +const referenceAlternatives = z.registry<{ readonly retained: true }>(); const borrowedDeclarations = new WeakMap(); /** Inspect authored declarations, including described clones, for catalog checks. */ @@ -25,6 +26,14 @@ export const declareEvidenceInput = ( return schema; }; +/** Mark a source union that accepts both inline Evidence and a retained reference. */ +export const declareEvidenceReferenceAlternative = ( + schema: Schema, +): Schema => { + referenceAlternatives.add(schema, { retained: true }); + return schema; +}; + /** Keep an SDK-borrowed selection associated with its owning declaration. */ export const declareBorrowedEvidenceInput = ( schema: Schema, @@ -94,6 +103,8 @@ type Visitor = ( export interface EvidenceInputContract { readonly frameSchema: z.ZodType; readonly hasEvidence: boolean; + /** A declared field can replace inline Evidence with a retained reference. */ + readonly hasEvidenceReferenceAlternative: boolean; resolve( input: unknown, visit: Visitor, @@ -102,9 +113,13 @@ export interface EvidenceInputContract { } const contracts = new WeakMap(); -const identity = (schema: z.ZodType): EvidenceInputContract => ({ +const identity = ( + schema: z.ZodType, + hasEvidenceReferenceAlternative = false, +): EvidenceInputContract => ({ frameSchema: schema, hasEvidence: false, + hasEvidenceReferenceAlternative, resolve: (input) => ok(input), }); @@ -129,11 +144,14 @@ export const getEvidenceInputContract = ( }; const compile = (schema: z.ZodType): EvidenceInputContract => { + if (referenceAlternatives.get(schema) !== undefined) + return identity(schema, true); const declared = declarations.get(schema); if (declared?.kind === "evidence") return { frameSchema: z.unknown().nonoptional(), hasEvidence: true, + hasEvidenceReferenceAlternative: true, resolve: (input, visit, path = []) => visit(input, path), }; if (declared?.kind === "borrowed") { @@ -156,9 +174,11 @@ const compile = (schema: z.ZodType): EvidenceInputContract => { schema instanceof z.ZodReadonly ) { const child = getEvidenceInputContract(schema.unwrap() as z.ZodType); - if (!child.hasEvidence) return identity(schema); + if (!child.hasEvidence) + return identity(schema, child.hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative: child.hasEvidenceReferenceAlternative, frameSchema: replaceWrappedSchema(schema, child.frameSchema), resolve: (input, visit, path = []) => input === undefined || @@ -185,9 +205,14 @@ const objectContract = (schema: z.ZodObject): EvidenceInputContract => { [key, getEvidenceInputContract(child as z.ZodType)] as const, ); const selected = children.filter(([, child]) => child.hasEvidence); - if (selected.length === 0) return identity(schema); + const hasEvidenceReferenceAlternative = children.some( + ([, child]) => child.hasEvidenceReferenceAlternative, + ); + if (selected.length === 0) + return identity(schema, hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative, frameSchema: schema.clone( { ...schema.def, @@ -215,9 +240,11 @@ const objectContract = (schema: z.ZodObject): EvidenceInputContract => { const arrayContract = (schema: z.ZodArray): EvidenceInputContract => { const child = getEvidenceInputContract(schema.element as z.ZodType); - if (!child.hasEvidence) return identity(schema); + if (!child.hasEvidence) + return identity(schema, child.hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative: child.hasEvidenceReferenceAlternative, frameSchema: schema.clone( { ...schema.def, element: child.frameSchema }, { parent: true }, @@ -242,7 +269,11 @@ const unionContract = (schema: z.ZodUnion): EvidenceInputContract => { const options = schema.options.map((option) => getEvidenceInputContract(option as z.ZodType), ); - if (!options.some((option) => option.hasEvidence)) return identity(schema); + const hasEvidenceReferenceAlternative = options.some( + (option) => option.hasEvidenceReferenceAlternative, + ); + if (!options.some((option) => option.hasEvidence)) + return identity(schema, hasEvidenceReferenceAlternative); // Capture the successful branch while parsing; choosing it again later would // repeat defaults, overwrites and refinements. Keys are only parser results. const branches = new WeakMap(); @@ -257,6 +288,7 @@ const unionContract = (schema: z.ZodUnion): EvidenceInputContract => { ); return { hasEvidence: true, + hasEvidenceReferenceAlternative, frameSchema: frame.clone({ ...frame.def, ...(schema.def.error === undefined ? {} : { error: schema.def.error }), diff --git a/src/domain/evidenceReference.ts b/src/domain/evidenceReference.ts index 51aed239d7..c4efba9230 100644 --- a/src/domain/evidenceReference.ts +++ b/src/domain/evidenceReference.ts @@ -4,6 +4,7 @@ import { absoluteLocalPathStringSchema } from "./localPath.js"; import { declareBorrowedEvidenceInput, declareEvidenceInput, + declareEvidenceReferenceAlternative, } from "./evidenceInputContract.js"; /** Exact reference to Evidence already retained by the current session. */ @@ -49,8 +50,7 @@ export const borrowEvidenceArray = () => { }; /** Evidence input, or one record read from a local Evidence file or bundle. */ -export const evidenceSourceInputSchema = z.union([ - ...evidenceInputSchema.options, - evidenceFileReferenceSchema, -]); +export const evidenceSourceInputSchema = declareEvidenceReferenceAlternative( + z.union([...evidenceInputSchema.options, evidenceFileReferenceSchema]), +); export type EvidenceSourceInput = z.output; diff --git a/src/recordedMcpCatalog.ts b/src/recordedMcpCatalog.ts index 23595540fe..e53b064340 100644 --- a/src/recordedMcpCatalog.ts +++ b/src/recordedMcpCatalog.ts @@ -16,6 +16,8 @@ export interface RecordedMcpCatalog { readonly instructions: string; readonly identity: ReturnType; readonly tools: readonly Tool[]; + /** Tool names derived from canonical Evidence-input declarations at build time. */ + readonly evidenceReferenceAlternatives: readonly string[]; readonly prompts: readonly (Prompt & { readonly parameters: Readonly>; })[]; diff --git a/src/server/DeferredEvidenceMcpServer.test.ts b/src/server/DeferredEvidenceMcpServer.test.ts index 6094693ff1..425ac88557 100644 --- a/src/server/DeferredEvidenceMcpServer.test.ts +++ b/src/server/DeferredEvidenceMcpServer.test.ts @@ -51,6 +51,7 @@ const catalog: RecordedMcpCatalog = { outputSchema: z.toJSONSchema(output), }, ], + evidenceReferenceAlternatives: [], prompts: [ { name: "explain", @@ -78,6 +79,7 @@ const fixture = async ( readonly invalidOutput?: boolean; readonly failAfterTool?: boolean; readonly maximumInputElements?: number; + readonly evidenceReferenceAlternative?: boolean; } = {}, ) => { const started = barrier(); @@ -85,7 +87,9 @@ const fixture = async ( const closed = vi.fn(); let loads = 0; const server = new DeferredEvidenceMcpServer( - catalog, + options.evidenceReferenceAlternative + ? { ...catalog, evidenceReferenceAlternatives: ["fixture"] } + : catalog, new ToolResultDelivery(1024 * 1024), async (target) => { loads++; @@ -188,6 +192,26 @@ it("refuses excess input before loading a deferred runtime, then admits a valid expect(f.calls).toHaveBeenCalledOnce(); }); +it("uses recorded Evidence-reference guidance without loading the deferred runtime", async () => { + const f = await fixture({ + maximumInputElements: 2, + evidenceReferenceAlternative: true, + }); + const result = await f.client.callTool({ + name: "fixture", + arguments: { n: 3, other: [0] }, + }); + const text = result.content.find((part) => part.type === "text")?.text; + expect(JSON.parse(text ?? "null")).toMatchObject({ + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + }, + }); + expect(f.loads()).toBe(0); + expect(f.calls).not.toHaveBeenCalled(); +}); + it("discovers without initialization, then shares one canonical runtime and preserves request metadata", async () => { const hold = barrier(); const f = await fixture({ hold }); diff --git a/src/server/DeferredEvidenceMcpServer.ts b/src/server/DeferredEvidenceMcpServer.ts index 31361fef69..6356a58d70 100644 --- a/src/server/DeferredEvidenceMcpServer.ts +++ b/src/server/DeferredEvidenceMcpServer.ts @@ -78,6 +78,8 @@ export class DeferredEvidenceMcpServer extends EvidenceMcpServerCore { "full", catalog.identity, ); + for (const name of catalog.evidenceReferenceAlternatives) + this.registerEvidenceReferenceAlternative(name); for (const tool of catalog.tools) { const { name, inputSchema, outputSchema, ...metadata } = tool; let binding: DeferredTool; diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 80600d4ace..accf187fa8 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -62,6 +62,7 @@ import { } from "./toolResultEncoding.js"; import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { canReferenceInlineEvidence } from "./evidenceInputRegistration.js"; import { currentMcpToolRequest, McpToolRequest, @@ -344,6 +345,11 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { | CompactInputSchemaPresentation | undefined; private readonly maximumInputElements: number; + private readonly evidenceReferenceAlternatives = new Set(); + + protected registerEvidenceReferenceAlternative(name: string): void { + this.evidenceReferenceAlternatives.add(name); + } constructor( info: Implementation, @@ -392,6 +398,8 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { }, callback: ToolCallback, ): RegisteredTool { + if (canReferenceInlineEvidence(config.inputSchema)) + this.registerEvidenceReferenceAlternative(name); const budget = this.compactPresentation?.budgetBytes; const inputSchema = budget !== undefined && @@ -467,6 +475,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { () => this.withAdmittedAnalysis, this.outputSchemaProfile, this.maximumInputElements, + this.evidenceReferenceAlternatives, ), ); } @@ -505,6 +514,7 @@ class ErrorEvidenceTransport implements Transport { private readonly currentAdmission: () => WithAdmittedAnalysis | undefined, private readonly outputSchemaProfile: McpOutputSchemaProfile, private readonly maximumInputElements: number, + private readonly evidenceReferenceAlternatives: ReadonlySet, ) { if (transport.hasPerRequestStream !== undefined) this.hasPerRequestStream = transport.hasPerRequestStream; @@ -968,8 +978,9 @@ class ErrorEvidenceTransport implements Transport { "The MCP tool arguments exceed the configured input element allowance; schema validation and tool execution did not start.", { max_input_elements: this.maximumInputElements }, { - remediationAction: - "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", + remediationAction: this.evidenceReferenceAlternatives.has(tool) + ? 'If the source Evidence is retained, use {"kind":"retained-evidence","evidence_id":"ev_..."} for declared Evidence fields; use a summary\'s parent_evidence_id for its complete source. Otherwise import the originating Evidence bundle when available. Reduce the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance.' + : "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", }, ); const result = this.delivery.toErrorToolResult(error, targetContent); diff --git a/src/server/evidenceInputRegistration.ts b/src/server/evidenceInputRegistration.ts new file mode 100644 index 0000000000..c46c1ed15b --- /dev/null +++ b/src/server/evidenceInputRegistration.ts @@ -0,0 +1,10 @@ +/** Internal registration facts derived from canonical Evidence input contracts. */ +const evidenceInputs = new WeakSet(); + +export const markEvidenceInput = (schema: T): T => { + evidenceInputs.add(schema); + return schema; +}; + +export const canReferenceInlineEvidence = (schema: unknown): boolean => + typeof schema === "object" && schema !== null && evidenceInputs.has(schema); diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts index 45723cef17..4062796304 100644 --- a/src/server/toolRegistrationOptions.ts +++ b/src/server/toolRegistrationOptions.ts @@ -9,6 +9,10 @@ import { toolInputSchemaWithMetadata, toolOutputSchemaWithMetadata, } from "../contracts/toolSchemaMetadata.js"; +import { + canReferenceInlineEvidence, + markEvidenceInput, +} from "./evidenceInputRegistration.js"; /** Project the canonical Zod contracts directly into SDK registration. */ export const toolRegistrationOptions = ( @@ -40,7 +44,9 @@ export const toolRegistrationOptions = ( return { title: contract.title, description: contract.description, - inputSchema, + inputSchema: evidenceInputs.hasEvidenceReferenceAlternative + ? markEvidenceInput(inputSchema) + : inputSchema, outputSchema: toolOutputSchemaWithMetadata(contract), annotations: contract.annotations, }; @@ -60,5 +66,10 @@ export const applicationToolRegistrationOptions = < validate: (value: unknown) => ({ value }), }, }; - return { ...registration, inputSchema }; + return { + ...registration, + inputSchema: canReferenceInlineEvidence(registration.inputSchema) + ? markEvidenceInput(inputSchema) + : inputSchema, + }; }; diff --git a/tests/acceptance/retainedComparisonAggregation.test.ts b/tests/acceptance/retainedComparisonAggregation.test.ts index 61a63170cf..0e7792e7a4 100644 --- a/tests/acceptance/retainedComparisonAggregation.test.ts +++ b/tests/acceptance/retainedComparisonAggregation.test.ts @@ -87,68 +87,113 @@ it("recovers a missing retained closure by importing the originating bundle", as expect(parseEvidence(retained.structuredContent)).toEqual(aggregate); }); -it("aggregates selected retained observations within a caller's input budget", async () => { - // Distinct authenticated observations may have identical derived contents. - const comparisons = Array.from({ length: 12 }, (_, observation) => - createEvidence(undefined, FUNCTION_COMPARISON_EVIDENCE.provider, { - operation: "compare_functions", - predicateType: "rea.function-comparison", - parameters: { ...FUNCTION_COMPARISON_EVIDENCE.parameters, observation }, - result: FUNCTION_COMPARISON_EVIDENCE.normalized_result, - confidence: "derived", - authority: "analyst-inference", - limitations: FUNCTION_COMPARISON_EVIDENCE.limitations, - evidenceLinks: FUNCTION_COMPARISON_EVIDENCE.evidence_links, - }), - ); - const directory = await createTestTempDirectory("rea-retained-comparisons-"); - const path = join(directory, "comparisons.json"); - await writeFile( - path, - JSON.stringify( - createEvidenceBundle([ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - ...comparisons, - ]), - ), - ); - const { call } = await connectLocalToolsMcp({ - REA_MCP_MAX_INPUT_ELEMENTS: "1024", - }); - const imported = await call("import_evidence_bundle", { path }); - expect(imported.isError, JSON.stringify(imported)).not.toBe(true); - const inline = await call("find_changed_behavior", { comparisons }); - expect(parseMcpToolError(inline).error).toMatchObject({ - code: "resource_constraint", - details: { reported_limits: { max_input_elements: 1024 } }, - }); - const response = await call("find_changed_behavior", { - comparisons: comparisons.map(({ evidence_id }) => ({ - kind: "retained-evidence", - evidence_id, - })), - }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - const aggregate = parseEvidence(response.structuredContent); - const result = changedBehaviorResultSchema.parse(aggregate.normalized_result); - expect(result.behavior_status).toBe("unknown"); - expect(result.summary.observed_changes).toBe(0); - expect(aggregate.parameters.comparison_evidence_ids).toEqual( - comparisons.map(({ evidence_id }) => evidence_id), - ); - expect( - new Set( - result.findings.items.map( - ({ source_comparison_id }) => source_comparison_id, +it.each(["compact", "full"] as const)( + "aggregates retained observations within the %s input profile budget", + async (profile) => { + // Distinct authenticated observations may have identical derived contents. + const comparisons = Array.from({ length: 12 }, (_, observation) => + createEvidence(undefined, FUNCTION_COMPARISON_EVIDENCE.provider, { + operation: "compare_functions", + predicateType: "rea.function-comparison", + parameters: { ...FUNCTION_COMPARISON_EVIDENCE.parameters, observation }, + result: FUNCTION_COMPARISON_EVIDENCE.normalized_result, + confidence: "derived", + authority: "analyst-inference", + limitations: FUNCTION_COMPARISON_EVIDENCE.limitations, + evidenceLinks: FUNCTION_COMPARISON_EVIDENCE.evidence_links, + }), + ); + const directory = await createTestTempDirectory( + "rea-retained-comparisons-", + ); + const path = join(directory, "comparisons.json"); + await writeFile( + path, + JSON.stringify( + createEvidenceBundle([ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + ...comparisons, + ]), ), - ), - ).toEqual(new Set(comparisons.map(({ evidence_id }) => evidence_id))); - expect(aggregate.evidence_links).toEqual( - expect.arrayContaining([ - FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, - FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, - ...comparisons.map(({ evidence_id }) => evidence_id), - ]), - ); -}); + ); + const { call } = await connectLocalToolsMcp({ + REA_MCP_MAX_INPUT_ELEMENTS: "1024", + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); + // Both refusals happen before any call initializes the deferred runtime. + const inline = await call("find_changed_behavior", { comparisons }); + expect(parseMcpToolError(inline).error).toMatchObject({ + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + details: { reported_limits: { max_input_elements: 1024 } }, + }); + const unrelated = parseMcpToolError( + await call("read_bytes", { + address: "0x1000", + length: 1, + extra: Array.from({ length: 1024 }, () => 0), + }), + ).error; + expect(unrelated.code).toBe("resource_constraint"); + expect(unrelated.remediation.action).not.toContain("retained-evidence"); + const inlineView = parseMcpToolError( + await call("inspect_analysis_view", { + source: { + kind: "inline", + evidence: { + ...FUNCTION_COMPARISON_EXAMPLE.left, + limitations: Array.from({ length: 1024 }, () => "limit"), + }, + }, + view: { kind: "summary" }, + }), + ).error; + expect(inlineView.code).toBe("resource_constraint"); + expect(inlineView.remediation.action).toContain("retained-evidence"); + const referenceOnly = parseMcpToolError( + await call("export_environment_bundle", { + source: { + kind: "retained-evidence", + evidence_id: FUNCTION_COMPARISON_EVIDENCE.evidence_id, + }, + output_directory: "/unused", + template_rules: Array.from({ length: 1024 }, () => "unused"), + }), + ).error; + expect(referenceOnly.code).toBe("resource_constraint"); + expect(referenceOnly.remediation.action).not.toContain("retained-evidence"); + const imported = await call("import_evidence_bundle", { path }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + const response = await call("find_changed_behavior", { + comparisons: comparisons.map(({ evidence_id }) => ({ + kind: "retained-evidence", + evidence_id, + })), + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const aggregate = parseEvidence(response.structuredContent); + const result = changedBehaviorResultSchema.parse( + aggregate.normalized_result, + ); + expect(result.behavior_status).toBe("unknown"); + expect(result.summary.observed_changes).toBe(0); + expect(aggregate.parameters.comparison_evidence_ids).toEqual( + comparisons.map(({ evidence_id }) => evidence_id), + ); + expect( + new Set( + result.findings.items.map( + ({ source_comparison_id }) => source_comparison_id, + ), + ), + ).toEqual(new Set(comparisons.map(({ evidence_id }) => evidence_id))); + expect(aggregate.evidence_links).toEqual( + expect.arrayContaining([ + FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, + FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + ...comparisons.map(({ evidence_id }) => evidence_id), + ]), + ); + }, +); diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index f52e3f166b..3a5e7ba962 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -670,23 +670,41 @@ const copyCompiledPackage = async (prefix: string): Promise => { return directory; }; +/** + * Modules `record-mcp-catalog.mjs` loads before it owns cleanup. The fixture + * replaces every one of these; an import outside the list fails closed. + */ +const RECORDER_RUNTIME_MODULES = [ + "server/createServer.js", + "composition/binary.js", + "config/parseConfig.js", + "logger.js", + "contracts/promptContracts.js", + "contracts/toolContracts.js", + "domain/evidenceInputContract.js", +] as const; + // Recorder cleanup tests replace every runtime import through their loader // hook. Keep only resolvable sentinels so an unmodeled import fails closed. const createRecorderCleanupFixture = async (): Promise => { const directory = await createTestTempDirectory("rea-catalog-cleanup-"); await mkdir(join(directory, "scripts"), { recursive: true }); - await cp( - resolve("scripts/record-mcp-catalog.mjs"), - join(directory, "scripts/record-mcp-catalog.mjs"), - ); + const scriptPath = resolve("scripts/record-mcp-catalog.mjs"); + const imported = [ + ...((await readFile(scriptPath, "utf8")).matchAll(/read\("([^"]+)"\)/g) ?? + []), + ].map((match) => match[1]); + if ( + imported.length !== RECORDER_RUNTIME_MODULES.length || + imported.some((name, index) => name !== RECORDER_RUNTIME_MODULES[index]) + ) { + throw new Error( + `record-mcp-catalog.mjs runtime imports ${JSON.stringify(imported)} do not match the cleanup fixture ${JSON.stringify(RECORDER_RUNTIME_MODULES)}`, + ); + } + await cp(scriptPath, join(directory, "scripts/record-mcp-catalog.mjs")); await cp(resolve("package.json"), join(directory, "package.json")); - for (const path of [ - "server/createServer.js", - "composition/binary.js", - "config/parseConfig.js", - "logger.js", - "contracts/promptContracts.js", - ]) { + for (const path of RECORDER_RUNTIME_MODULES) { const file = join(directory, "dist", path); await mkdir(dirname(file), { recursive: true }); await writeFile( @@ -771,6 +789,11 @@ it.each([ "/dist/logger.js": "export const silentLogger = {};", "/dist/contracts/promptContracts.js": "export const PROMPT_CONTRACTS = [];", + // The recorder derives Evidence-input names from these contracts. This + // fault injection has no tools, so the derived list stays empty. + "/dist/contracts/toolContracts.js": "export const TOOL_CONTRACTS = [];", + "/dist/domain/evidenceInputContract.js": + "export const getEvidenceInputContract = () => ({ hasEvidenceReferenceAlternative: false });", }; const clientModule = `data:text/javascript,${encodeURIComponent(` export class Client { @@ -803,6 +826,9 @@ registerHooks({ }, }); `)}`; + expect( + Object.keys(replacements).map((path) => path.slice("/dist/".length)), + ).toEqual([...RECORDER_RUNTIME_MODULES]); const child = spawnSync( process.execPath, [`--import=${hook}`, join(directory, "scripts/record-mcp-catalog.mjs")], diff --git a/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts b/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts index 0e95800cf4..162a21f606 100644 --- a/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts +++ b/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts @@ -108,7 +108,10 @@ it("keeps input capacity limits while composing dossiers that cannot be resent i arguments: { ...selection, functions: [source] }, }); expect(parseMcpToolError(inline)).toMatchObject({ - error: { code: "resource_constraint" }, + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + }, }); const retained = await client.callTool({ name: "build_call_path", diff --git a/tests/boundary/mcp/toolInputCapacityHttp.test.ts b/tests/boundary/mcp/toolInputCapacityHttp.test.ts index 9ac16a46e7..aa3619f865 100644 --- a/tests/boundary/mcp/toolInputCapacityHttp.test.ts +++ b/tests/boundary/mcp/toolInputCapacityHttp.test.ts @@ -136,6 +136,7 @@ processTest( }); } else { expect(error.code).toBe("resource_constraint"); + expect(error.remediation.action).toContain("retained-evidence"); expect(error.details).toMatchObject({ resource: "transport", reported_limits: { max_input_elements: 1_024 }, @@ -183,6 +184,9 @@ processTest( }); const calibratedError = parseMcpToolError(calibration).error; expect(calibratedError.code).toBe("resource_constraint"); + expect(calibratedError.remediation.action).not.toContain( + "retained-evidence", + ); expect(calibratedError.details).toMatchObject({ resource: "transport", reported_limits: { max_input_elements: 1_024 },