mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
5a22ae6bc8
* fix(mcp)!: return a compact binary_session status by default
binary_session {} with no target open returned about 66 KB, most of it
provider rejection diagnostics and candidate capability sets that the
skill's recovery flow re-reads on every retry. 6.8.0 grew it by 21%
because the shared regex capability limitation began repeating setting
bounds and step-by-step recovery for every search operation of every
provider (#6099, #6267, #6301, #7049) and IDA gained that limitation for
its two search operations (#4862).
The regex limitation now states the effective deadline and heap, their
sources and where to change them; the deadline and heap errors keep the
full recovery. binary_session gains detail: summary (default) or
complete. The summary keeps open state, target identity, the binding,
route capabilities, run, activity and health status, each candidate's
availability and target-support status, code and reason, and every
tool's availability, reason, remediation and modes, and lists the
fields it leaves out in omitted_fields. Complete detail returns the
previous shape. The CLI capabilities and providers commands keep
complete provider facts through the same application projection.
BREAKING CHANGE: binary_session {} no longer returns
analysis_provider_candidates[].availability.diagnostics,
target_support.diagnostics or capabilities, or tool_availability group
surface, effects and annotations. Pass {"detail": "complete"} to get
them.
Fixes #7109
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): key binary_session output fields by detail
The output schema accepted detail: "complete" with summary candidates or
tool groups, and a summary without omitted_fields, so detail did not
tell callers which fields existed. Each session kind is now crossed
with a detail-specific shape: a summary requires omitted_fields and
summary-only candidates and tool groups; complete requires diagnostics,
candidate capabilities and static tool flags and forbids omitted_fields.
Both the canonical schema and the SDK-validated advertised schema reject
mismatched shapes.
The verifier helper accepts summary candidates only for a validated MCP
summary; CLI provider output still requires candidate capabilities.
Regex limitation tests check values, sources and setting names instead
of whole sentences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { FULL_CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
|
|
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
|
import {
|
|
evidenceResultOf,
|
|
expandToolAvailability,
|
|
toolAvailabilitySummaryInventory,
|
|
} from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
|
|
const mainPath = fileURLToPath(
|
|
new URL("../../../dist/main.js", import.meta.url),
|
|
);
|
|
const fixturePath = fileURLToPath(
|
|
new URL("../../fixtures/fakeLauncher.mjs", import.meta.url),
|
|
);
|
|
|
|
const expectAvailableToolInventory = async (
|
|
client: Client,
|
|
listed: Awaited<ReturnType<Client["listTools"]>>,
|
|
) => {
|
|
const binarySession = listed.tools.find(
|
|
({ name }) => name === "binary_session",
|
|
);
|
|
if (binarySession === undefined)
|
|
throw new Error("The compiled catalog omitted binary_session");
|
|
const status = await client.callTool(
|
|
{ name: "binary_session", arguments: {} },
|
|
{ toolDefinition: binarySession },
|
|
);
|
|
expect(status.isError).not.toBe(true);
|
|
const inventory = z
|
|
.object({
|
|
result: z.object({
|
|
tool_availability: toolAvailabilitySummaryInventory,
|
|
}),
|
|
})
|
|
.parse(status.structuredContent).result.tool_availability;
|
|
const availability = expandToolAvailability(inventory);
|
|
// This asserts the compiled entrypoint matches the source catalog. When it
|
|
// fails, check that `dist/` is current (`npm run build:cached`) before
|
|
// suspecting a registration bug -- a stale build is by far the likeliest
|
|
// cause of a pure count mismatch here.
|
|
expect(
|
|
availability.length,
|
|
"compiled tool inventory is stale; run npm run build:cached",
|
|
).toBe(FULL_CATALOG_IDENTITY.counts.mcp_tools);
|
|
expect(new Set(listed.tools.map(({ name }) => name))).toEqual(
|
|
new Set(TOOL_CONTRACTS.map(({ name }) => name)),
|
|
);
|
|
return status;
|
|
};
|
|
|
|
/**
|
|
* Parses complete newline-delimited records, dropping any partial trailing
|
|
* line so a chunk boundary cannot turn into a JSON parse error.
|
|
*/
|
|
const completeStderrRecords = (stderr: string): unknown[] =>
|
|
stderr
|
|
.split("\n")
|
|
.slice(0, -1)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line): unknown => JSON.parse(line));
|
|
|
|
const callCurrentDocument = async (
|
|
target?: {
|
|
readonly path: string;
|
|
readonly kind?: "database";
|
|
},
|
|
inspectSession?: (client: Client) => Promise<void>,
|
|
) => {
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [mainPath],
|
|
cwd: process.cwd(),
|
|
env: {
|
|
PATH: process.env.PATH ?? "",
|
|
HOPPER_LAUNCHER_PATH: process.execPath,
|
|
...(target === undefined
|
|
? {}
|
|
: {
|
|
HOPPER_TARGET_PATH: target.path,
|
|
...(target.kind === undefined
|
|
? {}
|
|
: { HOPPER_TARGET_KIND: target.kind }),
|
|
}),
|
|
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]),
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk: Buffer | string) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
const client = new Client({ name: "runtime-smoke", version: "1.0.0" });
|
|
|
|
let result: Awaited<ReturnType<Client["callTool"]>>;
|
|
try {
|
|
await client.connect(transport);
|
|
result = await client.callTool({
|
|
name: "current_document",
|
|
arguments: {},
|
|
});
|
|
await inspectSession?.(client);
|
|
} finally {
|
|
await client.close();
|
|
await transport.close();
|
|
}
|
|
return { result, stderr, records: completeStderrRecords(stderr) };
|
|
};
|
|
|
|
describe("production stdio runtime", () => {
|
|
// The `test:acceptance` lane runs the real entrypoint on the current host;
|
|
// these cases state each supported host's result in the test name and body.
|
|
|
|
it("reports a missing target through MCP and structured logs", async () => {
|
|
const { result, stderr, records } = await callCurrentDocument();
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "text",
|
|
text: expect.stringContaining("No app is open."),
|
|
}),
|
|
]),
|
|
);
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({
|
|
application: "rea",
|
|
mode: "mcp",
|
|
layer: "server",
|
|
tool: "current_document",
|
|
status: "error",
|
|
}),
|
|
);
|
|
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");
|
|
expect(stderr).not.toContain(fixturePath);
|
|
}, 15_000);
|
|
|
|
it.runIf(process.platform === "darwin")(
|
|
"returns the selected document on macOS through MCP and structured logs",
|
|
async () => {
|
|
const { result, stderr, records } = await callCurrentDocument({
|
|
path: process.execPath,
|
|
});
|
|
expect(result.isError).not.toBe(true);
|
|
const observation = evidenceResultOf(z.literal("fixture")).parse(
|
|
result.structuredContent,
|
|
);
|
|
const evidence = parseEvidence(observation);
|
|
expect(evidence.operation).toBe("current_document");
|
|
expect(evidence.provider.id).toBe("hopper");
|
|
expect(evidence.normalized_result).toBe(observation.normalized_result);
|
|
// Hopper's reply is the result; it is not repeated as raw_result.
|
|
expect(evidence.raw_result).toBeNull();
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({
|
|
application: "rea",
|
|
mode: "mcp",
|
|
layer: "server",
|
|
tool: "current_document",
|
|
status: "ok",
|
|
}),
|
|
);
|
|
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");
|
|
expect(stderr).not.toContain(fixturePath);
|
|
},
|
|
15_000,
|
|
);
|
|
|
|
// Use the suite's bounded acceptance deadline, including its coverage allowance.
|
|
// This cold-start workflow computes the growing catalog's schema digests; it
|
|
// verifies catalog and lifecycle behavior, not a 15-second performance target.
|
|
it("serves the compiled catalog without an initial target or fatal record", async () => {
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [mainPath],
|
|
cwd: process.cwd(),
|
|
env: {
|
|
PATH: process.env.PATH ?? "",
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk: Buffer | string) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
const client = new Client({ name: "catalog-runtime", version: "1.0.0" });
|
|
|
|
try {
|
|
await client.connect(transport);
|
|
// Keep catalog parity independent of initial target/provider startup.
|
|
// Validate each called tool against its advertised definition without
|
|
// asking the SDK to compile every unrelated output schema in the catalog.
|
|
const listed = await client.listTools();
|
|
const status = await expectAvailableToolInventory(client, listed);
|
|
expect(
|
|
z
|
|
.object({ result: z.object({ open: z.boolean() }) })
|
|
.parse(status.structuredContent).result,
|
|
).toEqual({ open: false });
|
|
// Closing the empty session produces a structured lifecycle record,
|
|
// making the fatal-record check observable without launching a provider.
|
|
const closeBinary = listed.tools.find(
|
|
({ name }) => name === "close_binary",
|
|
);
|
|
if (closeBinary === undefined)
|
|
throw new Error("The compiled catalog omitted close_binary");
|
|
const closed = await client.callTool(
|
|
{ name: "close_binary", arguments: {} },
|
|
{ toolDefinition: closeBinary },
|
|
);
|
|
expect(closed.isError).not.toBe(true);
|
|
} finally {
|
|
await client.close();
|
|
await transport.close();
|
|
}
|
|
// The transport has closed, so the stderr pipe has ended and every record
|
|
// the child wrote has been delivered. Reading while the child is still
|
|
// running races the pipe and can observe an empty or partial buffer, which
|
|
// would silently miss the fatal record this test exists to catch.
|
|
const records = completeStderrRecords(stderr);
|
|
expect(
|
|
records.length,
|
|
"no startup records were observed, so the fatal-record check is vacuous",
|
|
).toBeGreaterThan(0);
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({ tool: "close_binary", status: "ok" }),
|
|
);
|
|
const fatal = records.filter(
|
|
(record): record is { level: number } =>
|
|
typeof record === "object" &&
|
|
record !== null &&
|
|
"level" in record &&
|
|
typeof record.level === "number" &&
|
|
record.level >= 50,
|
|
);
|
|
expect(fatal).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("production stdio saved-database admission", () => {
|
|
it.runIf(process.platform === "darwin")(
|
|
"refuses database analysis on macOS while keeping the session usable without a fatal record",
|
|
async () => {
|
|
const path = fileURLToPath(import.meta.url);
|
|
const { result, records } = await callCurrentDocument(
|
|
{ path, kind: "database" },
|
|
async (client) => {
|
|
const status = await expectAvailableToolInventory(
|
|
client,
|
|
await client.listTools(),
|
|
);
|
|
expect(status.structuredContent).toMatchObject({
|
|
result: {
|
|
open: true,
|
|
path,
|
|
kind: "database",
|
|
format: "analysis-database",
|
|
analysis_provider_binding: null,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
expect(parseMcpToolError(result)).toMatchObject({
|
|
error: {
|
|
code: "capability_unavailable",
|
|
category: "unsupported_provider",
|
|
retryable: false,
|
|
details: {
|
|
operation: "current_document",
|
|
selection_reason: "target_unsupported",
|
|
rejections: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
provider_id: "hopper",
|
|
code: "target_kind_unsupported",
|
|
reason: expect.stringContaining(
|
|
"cannot start REA's analysis bridge",
|
|
),
|
|
diagnostics: {
|
|
target_kind: "database",
|
|
target_format: "analysis-database",
|
|
architecture: null,
|
|
},
|
|
}),
|
|
]),
|
|
},
|
|
},
|
|
});
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({
|
|
tool: "current_document",
|
|
status: "error",
|
|
level: 40,
|
|
}),
|
|
);
|
|
expect(
|
|
records.some(
|
|
(record) =>
|
|
typeof record === "object" &&
|
|
record !== null &&
|
|
"level" in record &&
|
|
typeof record.level === "number" &&
|
|
record.level >= 50,
|
|
),
|
|
).toBe(false);
|
|
},
|
|
15_000,
|
|
);
|
|
});
|