fix: enforce console schema contract safeguards

This commit is contained in:
lda
2026-08-12 14:31:22 +07:00 Verified
parent 20e6ecc117
commit e0e1b718b8
9 changed files with 193 additions and 26 deletions
@@ -236,6 +236,25 @@ describe("useCapabilityDiscovery", () => {
expect(result.current.nextCursor).toBeNull();
});
it("stops automatic pagination when the server repeats a cursor", async () => {
const repeatedPage = deferred<CapabilityPage>();
client.list
.mockResolvedValueOnce(page([summary("serena.default.search")], "page-2"))
.mockReturnValueOnce(repeatedPage.promise)
.mockReturnValue(new Promise<CapabilityPage>(() => undefined));
const { result } = renderHook(() =>
useCapabilityDiscovery({ loadAllPages: true }),
);
await waitFor(() => expect(client.list).toHaveBeenCalledTimes(2));
repeatedPage.resolve(page([summary("wf.std.constant", "wf.std")], "page-2"));
await waitFor(() => expect(result.current.items).toHaveLength(2));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(client.list).toHaveBeenCalledTimes(2);
expect(result.current.nextCursor).toBe("page-2");
});
it("uses the applied filters when loading more after draft edits", async () => {
client.list
.mockResolvedValueOnce(page([summary("local.documents.read")], "page-2"))
@@ -107,6 +107,7 @@ export const useCapabilityDiscovery = (
const [state, setState] = useState<DiscoveryStateWithAppliedFilters>(initialState);
const listGenerationRef = useRef(0);
const inspectGenerationRef = useRef(0);
const automaticPageCursorsRef = useRef<Set<string>>(new Set());
const committedProvenanceRef = useRef<ConnectionProvenance | null>(null);
const listProvenanceRef = useRef<ConnectionProvenance | null>(null);
const selectedProvenanceRef = useRef<ConnectionProvenance | null>(null);
@@ -123,7 +124,10 @@ export const useCapabilityDiscovery = (
if (!client || currentProvenance === null) return;
const requestProvenance = currentProvenance;
const generation = ++listGenerationRef.current;
if (append === false) inspectGenerationRef.current++;
if (append === false) {
inspectGenerationRef.current++;
automaticPageCursorsRef.current.clear();
}
listProvenanceRef.current = requestProvenance;
setState((current) => ({
...current,
@@ -226,8 +230,11 @@ export const useCapabilityDiscovery = (
useEffect(() => {
if (!loadAllPages || state.phase !== "ready" || state.nextCursor === null) return;
if (automaticPageCursorsRef.current.has(state.nextCursor)) return;
automaticPageCursorsRef.current.add(state.nextCursor);
// Authoring palettes need the complete catalog because they do not expose
// discovery pagination; continue one page at a time through the same guarded loader.
// discovery pagination. Stop when a server repeats a cursor rather than
// hiding an infinite request loop behind item deduplication.
loadMore();
}, [loadAllPages, loadMore, state.nextCursor, state.phase]);
@@ -171,6 +171,20 @@ describe("normalizeSchema", () => {
hasDefault: true,
defaultValue: "source_resource_ref",
});
expect(ref?.children.find((child) => child.key === "mime_type")).toMatchObject({
kind: "string",
title: "Mime Type",
hasDefault: true,
defaultValue: null,
fallbackReason: null,
});
expect(ref?.children.find((child) => child.key === "name")).toMatchObject({
kind: "string",
title: "Name",
hasDefault: true,
defaultValue: null,
fallbackReason: null,
});
});
it("falls back deterministically for an object reference cycle through a property", () => {
@@ -45,6 +45,25 @@ const isRecord = (value: unknown): value is SchemaRecord =>
const hasOwn = (value: SchemaRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const nullableNonNullSchema = (schema: SchemaRecord): SchemaRecord | null => {
const anyOf = schema.anyOf;
if (!Array.isArray(anyOf) || anyOf.length !== 2) return null;
const nullBranches = anyOf.filter(
(branch) => isRecord(branch) && branch.type === "null",
);
const nonNullBranches = anyOf.filter(
(branch): branch is SchemaRecord => isRecord(branch) && branch.type !== "null",
);
if (nullBranches.length !== 1 || nonNullBranches.length !== 1) return null;
const annotations = Object.fromEntries(
["default", "description", "title"]
.filter((key) => hasOwn(schema, key))
.map((key) => [key, schema[key]]),
);
return { ...nonNullBranches[0], ...annotations };
};
const stringValue = (value: unknown): string | null =>
typeof value === "string" ? value : null;
@@ -134,7 +153,30 @@ const normalizeField = (
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
return fallback(schema, path, key, required, title, resolution.reason);
}
const resolvedSchema = resolution.schema;
let resolvedSchema = resolution.schema;
let resolvedReferenceAncestry = resolution.referenceAncestry;
if (isRecord(resolvedSchema)) {
const nullableSchema = nullableNonNullSchema(resolvedSchema);
if (nullableSchema !== null) {
const nullableResolution = resolveLocalSchemaNodeWithAncestry(
rootSchema,
nullableSchema,
resolvedReferenceAncestry,
);
if (!nullableResolution.ok) {
return fallback(
resolvedSchema,
path,
key,
required,
stringValue(resolvedSchema.title) ?? defaultTitle,
nullableResolution.reason,
);
}
resolvedSchema = nullableResolution.schema;
resolvedReferenceAncestry = nullableResolution.referenceAncestry;
}
}
const title = isRecord(resolvedSchema)
? stringValue(resolvedSchema.title) ?? defaultTitle
: defaultTitle;
@@ -183,7 +225,7 @@ const normalizeField = (
propertyKey,
requiredNames.has(propertyKey),
stringValue(propertySchema && isRecord(propertySchema) ? propertySchema.title : null) ?? propertyKey,
resolution.referenceAncestry,
resolvedReferenceAncestry,
depth + 1,
),
)
@@ -216,7 +258,7 @@ const normalizeField = (
"item",
true,
`${title} item`,
resolution.referenceAncestry,
resolvedReferenceAncestry,
depth + 1,
);
return {