fix: harden assistant authoring and UI

This commit is contained in:
ajnart authored and Thomas Camlong committed 2026-08-09 18:09:11 +02:00
1 parent fa0e55cc93
commit edc7b4d55b
51 files changed
+4118 -916

No files matched your search

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
import {
buildCustomWidgetAiPrompt,
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
} from "../core/ai-prompt";
describe("AI prompt", () => {
it("is self-contained, request-first, and compact", () => {
@@ -36,6 +40,18 @@ describe("AI prompt", () => {
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
});
it("makes the connected MCP workflow load the complete skill and test every preview query", () => {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("First call customWidget_getSkill");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_validate");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewCreate");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewQuery once for every query");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_getSharedProps");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
);
});
it("redacts secrets and preserves the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
@@ -5,28 +5,73 @@ import { describe, expect, it } from "vitest";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
describe("authoring resources", () => {
it("returns the concise skills.sh discovery shape", () => {
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.0" });
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.2.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
expect(skill).not.toHaveProperty("files");
expect(skill).not.toHaveProperty("componentCatalog");
expect(getCustomWidgetSkillContent()).toBe(skill.skillMd);
expect(skill.skillMd.trim()).toBe(
readFileSync(path.join(process.cwd(), ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8").trim(),
);
expect(skill).not.toHaveProperty("entrypointMd");
expect(skill).not.toHaveProperty("bundledMarkdown");
expect(skill.skillMd.trim()).toBe(readFileSync(path.join(skillSourceDirectory, "SKILL.md"), "utf8").trim());
const bundledContent = getCustomWidgetSkillContent();
for (const [file, content] of Object.entries(skill.references)) {
expect(content.trim()).toBe(readFileSync(path.join(skillSourceDirectory, file), "utf8").trim());
expect(bundledContent).toContain(`# Bundled file: ${file}`);
expect(bundledContent).toContain(content.trim());
}
expect(bundledContent).toContain(skill.skillMd.trim());
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
});
it("keeps the full catalog retrieval-based", () => {
expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
it("keeps catalog discovery compact and component details lazy", () => {
const catalog = getCustomWidgetComponentCatalog();
expect(catalog.components.length).toBeGreaterThan(100);
expect(catalog.components).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
);
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
fetchTool: "customWidget_getSharedProps",
maxPerRequest: 64,
});
expect(catalog.sharedProps.names).toContain("p");
expect(Buffer.byteLength(JSON.stringify(catalog), "utf8")).toBeLessThan(45_000);
const stack = getCustomWidgetComponent("Stack");
expect(stack).toMatchObject({
name: "Stack",
sharedProps: { fetchTool: "customWidget_getSharedProps" },
});
expect(stack?.props.some(({ source }) => source === "global")).toBe(false);
expect(Buffer.byteLength(JSON.stringify(stack), "utf8")).toBeLessThan(20_000);
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
const largestComponentPayload = Math.max(
...catalog.components.map(({ name }) =>
Buffer.byteLength(JSON.stringify(getCustomWidgetComponent(name)), "utf8"),
),
);
expect(largestComponentPayload).toBeLessThan(20_000);
});
it("returns only requested shared prop documentation in one bounded payload", () => {
const result = getCustomWidgetSharedProps(["p", "m", "not-a-shared-prop", "p"]);
expect(result.props.map(({ name }) => name)).toEqual(["p", "m"]);
expect(result.notFound).toEqual(["not-a-shared-prop"]);
expect(Buffer.byteLength(JSON.stringify(result), "utf8")).toBeLessThan(4_000);
const boundedBatch = getCustomWidgetSharedProps(getCustomWidgetComponentCatalog().sharedProps.names.slice(0, 64));
expect(Buffer.byteLength(JSON.stringify(boundedBatch), "utf8")).toBeLessThan(15_000);
});
});
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
import {
CUSTOM_WIDGET_STARTER,
customWidgetAuthoringDefinitionSchema,
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
customJsxRequestSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
hasSameCustomWidgetSourceAuthentication,
applyCustomWidgetSourceSetup,
normalizeCustomWidgetAuthoringDefinition,
} from "../core";
describe("lean Custom Widget schema", () => {
@@ -61,6 +64,56 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
it("accepts and normalizes realistic multiline JSX without JSON string escaping", () => {
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
const templateLines = [
'<Stack gap="sm">',
' <Group justify="space-between">',
" <Text fw={700}>Upcoming fixtures</Text>",
' <Badge color="blue">{(data.fixtures ?? []).length} matches</Badge>',
" </Group>",
" {(data.fixtures ?? []).map(fixture => (",
' <Paper key={fixture.id} p="sm" withBorder>',
" <Text>{fixture.home_team.name} vs {fixture.away_team.name}</Text>",
" </Paper>",
" ))}",
"</Stack>",
];
const candidate = customWidgetAuthoringDefinitionSchema.parse({
...definition,
requests: { fixtures: { path: "/fixtures" } },
templateLines,
});
expect(normalizeCustomWidgetAuthoringDefinition(candidate).template).toBe(templateLines.join("\n"));
});
it("requires exactly one authoring template representation", () => {
expect(
customWidgetAuthoringDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, templateLines: ["<Text />"] })
.success,
).toBe(false);
const { template: _template, ...withoutTemplate } = CUSTOM_WIDGET_STARTER;
expect(customWidgetAuthoringDefinitionSchema.safeParse(withoutTemplate).success).toBe(false);
});
it("keeps omitted update fields absent when using templateLines", () => {
expect(customWidgetUpdateSchema.parse({ id: "widget", templateLines: ["<Text />"] })).toEqual({
id: "widget",
templateLines: ["<Text />"],
});
});
it("applies the template size limit before joining templateLines", () => {
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
expect(
customWidgetAuthoringDefinitionSchema.safeParse({
...definition,
templateLines: Array.from({ length: 6 }, () => "x".repeat(10_000)),
}).success,
).toBe(false);
});
it("rejects source IDs that collide case-insensitively", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,