mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
* test(mcp): pin Kimi/Moonshot-accepted input schemas for #9581
Closes #9581
#9581 reports Kimi/Moonshot rejecting 9 tools whose input schemas
carried a $ref to a oneOf/anyOf with a nested $ref. The inlined
advertisement that fixes this is already on main (reused: "inline"
in toolInputSchemaWithMetadata, 554816be, unreleased): all 246
advertised input schemas are reference-free in both profiles. This
adds the regression coverage that pins it: the issue's minimal
trigger through the compact presenter, and the rejected reference
pattern checked across every served input schema in both profiles,
naming the nine tools from the issue.
Not verified against live Kimi/Moonshot (no access); verified locally
that no advertised input schema in either profile contains the
rejected pattern.
* test(javascript): distinguish package scope in route fixtures
* test(apple): separate identity and refusal fixture groups
* test(mcp): qualify reference detector and semantic inlining
---------
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
1 parent
7a1d89a801
commit
b105dae7c6
2 files changed
+217
No files matched your search
@@ -224,6 +224,59 @@ describe("compact advertised input schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("inlines the Kimi/Moonshot-rejected reference pattern from #9581", () => {
|
||||
// Minimal trigger from morluto/rea#9581: Moonshot rejects a $ref whose
|
||||
// target is a oneOf whose branches contain another $ref ("detected
|
||||
// infinite recursion without termination condition"), even though none
|
||||
// of these schemas actually recurse. The compact presentation inlines
|
||||
// the whole shape, removing the locally reproduced rejected reference pattern.
|
||||
const trigger = {
|
||||
type: "object",
|
||||
properties: { c: { $ref: "#/$defs/u" } },
|
||||
$defs: {
|
||||
u: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { $ref: "#/$defs/s" } },
|
||||
required: ["a"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { $ref: "#/$defs/t" } },
|
||||
required: ["a"],
|
||||
},
|
||||
],
|
||||
},
|
||||
s: { type: "string" },
|
||||
t: { type: "number" },
|
||||
},
|
||||
};
|
||||
const { schema, presentation } = compactAdvertisedInputSchema(
|
||||
trigger,
|
||||
// COMPACT_INPUT_SCHEMA_BUDGET_BYTES (13 KiB); the contracts layer
|
||||
// cannot import the config layer, so the budget stays literal here.
|
||||
13 * 1024,
|
||||
);
|
||||
expect(presentation).toBe("inline");
|
||||
expect(schema.$defs).toBeUndefined();
|
||||
expect(JSON.stringify(schema)).not.toContain('"$ref":');
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
const canonical = ajv.compile(trigger);
|
||||
const compact = ajv.compile(schema);
|
||||
for (const [input, expected] of [
|
||||
[{ c: { a: "text" } }, true],
|
||||
[{ c: { a: 7 } }, true],
|
||||
[{ c: { a: false } }, false],
|
||||
[{ c: {} }, false],
|
||||
[{}, true],
|
||||
[{ c: null }, false],
|
||||
] as const) {
|
||||
expect(canonical(input)).toBe(expected);
|
||||
expect(compact(input)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
describe("strict gateway compatibility", () => {
|
||||
it("types homogeneous union items without discarding their branches", () => {
|
||||
const original = {
|
||||
|
||||
@@ -80,6 +80,121 @@ const unsupportedDependencyKeywords = (
|
||||
});
|
||||
};
|
||||
|
||||
// Moonshot rejects a $ref whose target is a oneOf/anyOf whose branches
|
||||
// contain another $ref (morluto/rea#9581): "detected infinite recursion
|
||||
// without termination condition". The rejection is about the reference
|
||||
// shape, not the union itself, so this checks the pattern rather than
|
||||
// banning references outright: a genuinely recursive schema still needs
|
||||
// $ref, but never in this shape.
|
||||
const resolveLocalPointer = (root: unknown, reference: string): unknown => {
|
||||
if (!reference.startsWith("#/")) return undefined;
|
||||
let current: unknown = root;
|
||||
for (const token of reference
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))) {
|
||||
if (typeof current !== "object" || current === null) return undefined;
|
||||
current = (current as Record<string, unknown>)[token];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const collectSchemaReferences = (
|
||||
node: unknown,
|
||||
path: string,
|
||||
collected: { path: string; reference: string }[],
|
||||
): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child, index) =>
|
||||
collectSchemaReferences(child, `${path}[${index}]`, collected),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || node === null) return;
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "$ref" && typeof value === "string") {
|
||||
collected.push({ path: `${path}.$ref`, reference: value });
|
||||
continue;
|
||||
}
|
||||
if (["const", "enum", "default", "examples"].includes(key)) continue;
|
||||
if (schemaMaps.has(key) && typeof value === "object" && value !== null) {
|
||||
for (const [name, child] of Object.entries(value))
|
||||
collectSchemaReferences(child, `${path}.${key}.${name}`, collected);
|
||||
} else if (schemaArrays.has(key) && Array.isArray(value)) {
|
||||
value.forEach((child, index) =>
|
||||
collectSchemaReferences(child, `${path}.${key}[${index}]`, collected),
|
||||
);
|
||||
} else if (schemaChildren.has(key)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((child, index) =>
|
||||
collectSchemaReferences(child, `${path}.${key}[${index}]`, collected),
|
||||
);
|
||||
else collectSchemaReferences(value, `${path}.${key}`, collected);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Paths of $refs Moonshot rejects: targets that are a oneOf/anyOf with a nested $ref. */
|
||||
const moonshotRejectedReferences = (
|
||||
schema: Record<string, unknown>,
|
||||
): readonly string[] => {
|
||||
const collected: { path: string; reference: string }[] = [];
|
||||
collectSchemaReferences(schema, "$", collected);
|
||||
return collected
|
||||
.filter(({ reference }) => {
|
||||
const target = resolveLocalPointer(schema, reference);
|
||||
if (typeof target !== "object" || target === null) return false;
|
||||
for (const key of ["oneOf", "anyOf"]) {
|
||||
const branches = (target as Record<string, unknown>)[key];
|
||||
if (!Array.isArray(branches)) continue;
|
||||
const nested: { path: string; reference: string }[] = [];
|
||||
for (const branch of branches)
|
||||
collectSchemaReferences(branch, "$", nested);
|
||||
if (nested.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(({ path }) => path);
|
||||
};
|
||||
|
||||
it("distinguishes rejected references from allowed references and literal data", () => {
|
||||
const union = {
|
||||
oneOf: [
|
||||
{ type: "object", properties: { a: { $ref: "#/$defs/s" } } },
|
||||
{ type: "object", properties: { a: { $ref: "#/$defs/t" } } },
|
||||
],
|
||||
};
|
||||
const definitions = { s: { type: "string" }, t: { type: "number" } };
|
||||
const rejected = {
|
||||
type: "object",
|
||||
properties: { c: { $ref: "#/$defs/u" } },
|
||||
$defs: { ...definitions, u: union },
|
||||
};
|
||||
expect(moonshotRejectedReferences(rejected)).toEqual(["$.properties.c.$ref"]);
|
||||
expect(
|
||||
moonshotRejectedReferences({
|
||||
type: "object",
|
||||
properties: { c: { $ref: "#/$defs/s" } },
|
||||
$defs: definitions,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
moonshotRejectedReferences({
|
||||
type: "object",
|
||||
properties: { c: { $ref: "#/$defs/a~1b~0c" } },
|
||||
$defs: { ...definitions, "a/b~c": union },
|
||||
}),
|
||||
).toEqual(["$.properties.c.$ref"]);
|
||||
expect(
|
||||
moonshotRejectedReferences({
|
||||
const: rejected,
|
||||
enum: [rejected],
|
||||
default: rejected,
|
||||
examples: [rejected],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
const listToolsThroughTransport = async (
|
||||
server: EvidenceMcpServer,
|
||||
): Promise<readonly AdvertisedTool[]> => {
|
||||
@@ -482,6 +597,55 @@ it("retains parameter types and avoids client-side reference expansion", async (
|
||||
});
|
||||
});
|
||||
|
||||
it("advertises Moonshot-accepted input schemas without the rejected reference pattern in both profiles", async () => {
|
||||
// morluto/rea#9581: Kimi/Moonshot rejects a $ref whose target is a
|
||||
// oneOf/anyOf with another $ref inside, which broke every Kimi request
|
||||
// because clients send the whole tool list. The nine tools named in the
|
||||
// issue must stay advertised with inlined schemas in both profiles.
|
||||
const rejectedInIssue = [
|
||||
"capture_browser_scenario",
|
||||
"capture_native_ui_scenario",
|
||||
"observe_native_calls",
|
||||
"inspect_analysis_view",
|
||||
"compare_web_captures",
|
||||
"trace_javascript_semantics",
|
||||
"compare_process_captures",
|
||||
"correlate_static_and_runtime",
|
||||
"verify_reconstruction",
|
||||
];
|
||||
for (const profile of ["full", "compact"] as const) {
|
||||
const server = profile === "full" ? fullServer() : compactServer();
|
||||
registerCatalog(server);
|
||||
const tools = await listToolsThroughTransport(server);
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
for (const name of rejectedInIssue) {
|
||||
const tool = byName.get(name);
|
||||
if (tool === undefined)
|
||||
throw new Error(`${profile} profile: ${name} was not advertised`);
|
||||
expect(
|
||||
tool.inputSchema,
|
||||
`${profile} profile: ${name}`,
|
||||
).not.toHaveProperty("$defs");
|
||||
expect(
|
||||
moonshotRejectedReferences(tool.inputSchema),
|
||||
`${profile} profile: ${name}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
expect(
|
||||
tools.flatMap(({ name, inputSchema }) =>
|
||||
moonshotRejectedReferences(inputSchema).map(
|
||||
(path) => `${profile} profile: ${name}${path.slice(1)}`,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
tools
|
||||
.filter(({ inputSchema }) => Object.hasOwn(inputSchema, "$defs"))
|
||||
.map(({ name }) => `${profile} profile: ${name}`),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses the profile selection the server startup reads", () => {
|
||||
expect(parseMcpInputSchemaProfile("compact")).toEqual({
|
||||
ok: true,
|
||||
|
||||
Reference in new issue
Block a user