diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md index 3121374ab..a067f4a54 100644 --- a/.agents/skills/homarr-custom-widget/SKILL.md +++ b/.agents/skills/homarr-custom-widget/SKILL.md @@ -11,7 +11,7 @@ Author one widget at a time. When connected to Homarr, retrieve `homarr://custom 2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`. 3. Validate, preview, test queries and simulated actions, visually inspect, and iterate. 4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext. -5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. +5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with `customWidget_createFromPreview`; do not resend a large template through `customWidget_create` unless no preview session is available. Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Templates read `data`, `status`, `options`, and temporary `inputs`. diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index aecf20fd5..e2032ee37 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -240,8 +240,13 @@ runtime and security references, and the current JSON Schema to the trusted auth extra provider round trip for the large `customWidget_getSkill` and `customWidget_schema` results. If the model discovers the Custom Widget workflow mid-response, Homarr promotes the same resources before its next step and removes the now-redundant resource calls from provider history. Multiline JSX uses `templateLines` to avoid fragile -streamed JSON escaping. The assistant validates the candidate, creates a preview, executes every returned preview query, -checks the actual HTTP status and response shape, and iterates before saving. Preview and saved-definition results +streamed JSON escaping. Custom Widget authoring tools are mandatory: the assistant validates the complete candidate, +creates a preview from that exact version, executes every returned preview query, checks the actual HTTP status and +response shape, and repeats the entire validation and preview sequence after any material JSX, request, source, option, +or binding change. It only reports a widget as validated, working, created, or placed when the corresponding tool result +proves that checkpoint. Large widgets are saved from the exact final tested preview session, so the approved create step +does not stream the complete JSX a second time. When the request already names a board, that board is carried into the +create result and placement continues without asking the user to select it again. Preview and saved-definition results include safe Homarr paths that open in the assistant without a page reload. When placement is still a choice, the assistant uses the structured question UI instead of ending with a prose question. diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts index 26d16b669..59cfcae00 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts @@ -30,8 +30,13 @@ describe("withAssistantToolPolicy", () => { describe("customWidgetAssistantInstructions", () => { test("requires the complete skill and real preview-query data before creation", () => { + expect(customWidgetAssistantInstructions).toContain("Custom Widget tools are mandatory"); + expect(customWidgetAssistantInstructions).toContain("only a manifest, JSX, instructions"); expect(customWidgetAssistantInstructions).toContain("when they are preloaded below"); expect(customWidgetAssistantInstructions).toContain("do not call customWidget_getSkill or customWidget_schema"); + expect(customWidgetAssistantInstructions).toContain( + "already loaded their complete contents into this system prompt", + ); expect(customWidgetAssistantInstructions).toContain("first call customWidget_getSkill"); expect(customWidgetAssistantInstructions).toContain("customWidget_schema"); expect(customWidgetAssistantInstructions).toContain("customWidget_getComponentCatalog"); @@ -39,8 +44,15 @@ describe("customWidgetAssistantInstructions", () => { expect(customWidgetAssistantInstructions).toContain("customWidget_getSharedProps once"); expect(customWidgetAssistantInstructions).toContain("customWidget_getExample"); expect(customWidgetAssistantInstructions).toContain("templateLines"); - expect(customWidgetAssistantInstructions).toContain("customWidget_previewQuery for every query"); - expect(customWidgetAssistantInstructions).toContain("Call customWidget_create only after"); + expect(customWidgetAssistantInstructions).toContain("installed `pokedex` example"); + expect(customWidgetAssistantInstructions).toContain("Honor an explicit iteration count"); + expect(customWidgetAssistantInstructions).toContain("customWidget_validate with the complete definition"); + expect(customWidgetAssistantInstructions).toContain("customWidget_previewQuery for every query returned"); + expect(customWidgetAssistantInstructions).toContain("the previous validation and preview evidence is stale"); + expect(customWidgetAssistantInstructions).toContain("Never substitute a later unvalidated version"); + expect(customWidgetAssistantInstructions).toContain("customWidget_createFromPreview"); + expect(customWidgetAssistantInstructions).toContain("Never say the widget is created, updated, placed"); + expect(customWidgetAssistantInstructions).toContain("corresponding customWidget_create, customWidget_update"); expect(customWidgetAssistantInstructions).toContain("managementPath"); }); @@ -191,6 +203,28 @@ describe("getRequiredAssistantToolNames", () => { toolCallId: "create-widget-1", input: { name: "PSG fixtures" }, state: "output-available", + output: { + id: "widget-1", + managementPath: "/manage/custom-widgets/edit/widget-1", + nextAction: { + type: "place-custom-widget", + targetBoardId: "board-hey", + }, + }, + }), + ]), + ).toEqual(["configure_widget"]); + }); + + test("requires placement after persisting the final tested preview", () => { + expect( + getRequiredAssistantToolNames([ + assistantMessage({ + type: "dynamic-tool", + toolName: "customWidget_createFromPreview", + toolCallId: "create-widget-from-preview-1", + input: { previewSessionId: "preview-3" }, + state: "output-available", output: { id: "widget-1", managementPath: "/manage/custom-widgets/edit/widget-1", diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts index 63a67a033..72e3072a3 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts @@ -7,10 +7,19 @@ const mutationApprovalInstruction = export const customWidgetAssistantInstructions = ` Custom Widget authoring rules: -- For every request to create, repair, validate, preview, or explain a Custom JSX widget, use the complete installed skill and JSON Schema from the trusted Custom Widget authoring context when they are preloaded below; do not call customWidget_getSkill or customWidget_schema in that case. When they are not preloaded, first call customWidget_getSkill, whose response contains the complete installed skill and all references, then call customWidget_schema. Follow those resources as authoritative. Next call the compact customWidget_getComponentCatalog for the installed release. Fetch only the relevant named component documentation with customWidget_getComponent and, when its interaction pattern applies, one named example with customWidget_getExample before authoring. Component results contain only component-specific props; if the design needs shared props such as spacing or color, collect their names from the catalog and call customWidget_getSharedProps once with only those names. +- Custom Widget tools are mandatory for Custom JSX work. Never answer a request to create, repair, or materially change a Custom Widget with only a manifest, JSX, instructions, or an unsupported claim that it should work. +- For every request to create, repair, validate, preview, or explain a Custom JSX widget, use the complete installed skill and JSON Schema from the trusted Custom Widget authoring context when they are preloaded below; do not call customWidget_getSkill or customWidget_schema in that case because Homarr has already loaded their complete contents into this system prompt. When they are not preloaded, first call customWidget_getSkill, whose response contains the complete installed skill and all references, then call customWidget_schema. Do not merely say that you loaded them: use their rules for the subsequent tool inputs. Follow those resources as authoritative. Next call the compact customWidget_getComponentCatalog for the installed release. Fetch only the relevant named component documentation with customWidget_getComponent and, when its interaction pattern applies, one named example with customWidget_getExample before authoring. Component results contain only component-specific props; if the design needs shared props such as spacing or color, collect their names from the catalog and call customWidget_getSharedProps once with only those names. - Use templateLines for multiline JSX tool input. Do not send a heavily escaped multiline template string when templateLines is accepted. -- Never skip the authoring lifecycle: customWidget_validate, repair all issues, customWidget_previewCreate, then customWidget_previewQuery for every query listed by the preview. Inspect the real status and returned data shape, correct request paths and template bindings, and repeat validation and preview after changes. Call customWidget_create only after the definition and its preview queries succeed. -- A preview session is not evidence that its API queries work. Never claim live data works until customWidget_previewQuery returned it. Use the returned previewPath when the user should inspect the rendered result. +- Keep each tool call within a reliable streamed size. For Pokédex or PokéAPI requests, start from the installed \`pokedex\` example returned by customWidget_getExample and improve it through validated iterations instead of generating one enormous untested JSX argument from scratch. Completeness comes from working interactions and states, not gratuitous line count. +- Honor an explicit iteration count. Each requested iteration must make a meaningful improvement and complete its own validation, preview, and required preview-query checks. Persist only the last tested preview session. +- Never skip or reorder the evidence checkpoints for the exact candidate that will be saved: + 1. Call customWidget_validate with the complete definition. Repair every reported issue and call it again until it succeeds. + 2. Call customWidget_previewCreate with that same validated definition. + 3. Call customWidget_previewQuery for every query returned by the preview. Inspect the real HTTP status and returned data shape. Test relevant simulated actions with customWidget_previewAction when the widget contains actions. + 4. If the JSX template, request definitions, sources, options, or bindings change materially at any point, the previous validation and preview evidence is stale. Start again at customWidget_validate with the complete revised definition, create a fresh preview, and retest every returned query. + 5. For a new widget, call customWidget_createFromPreview with the final tested preview session ID so the approved mutation persists that exact candidate without streaming the complete JSX again. If the user already named a target board, resolve its ID before creation and include targetBoardId; after creation call configure_widget directly and do not ask the user to choose again. Use customWidget_create only when no preview session can be reused. For an update, call customWidget_update only with the final definition whose latest validation, preview, and required query checks succeeded. Never substitute a later unvalidated version. +- A successful syntax validation proves only that the definition is valid. A preview session proves only that it can be rendered. Never claim live data works until every customWidget_previewQuery succeeded with the expected response shape. Never say the widget is created, updated, placed, or fully working unless the corresponding customWidget_create, customWidget_update, or board placement tool returned success. If a tool fails, report the specific failed checkpoint and continue repairing it when possible instead of describing the widget as done. +- Progress updates may say which checkpoint is running, but they must not claim success before the tool result exists. Use the returned previewPath when the user should inspect the rendered result. - After creating a widget, include the returned managementPath as a Markdown link so the user can open and edit the exact widget in Homarr. - If the user must choose what happens next, such as whether to add the created widget to a board or leave it unplaced, call ask_user with explicit choices. Never end a custom-widget response with a prose question that expects a typed answer. Open-ended conversation and purely rhetorical questions do not require ask_user; state optional next steps declaratively instead of asking about them.`; @@ -58,7 +67,16 @@ type AssistantToolResponseMessage = { content: unknown; }; -const isSuccessfulCustomWidgetCreateOutput = (output: unknown) => +const customWidgetCreateToolNames = new Set(["customWidget_create", "customWidget_createFromPreview"]); + +type SuccessfulCustomWidgetCreateOutput = { + id: string; + managementPath: string; + nextAction?: unknown; + error?: undefined; +}; + +const isSuccessfulCustomWidgetCreateOutput = (output: unknown): output is SuccessfulCustomWidgetCreateOutput => typeof output === "object" && output !== null && "id" in output && @@ -69,6 +87,18 @@ const isSuccessfulCustomWidgetCreateOutput = (output: unknown) => output.managementPath.length > 0 && (!("error" in output) || output.error === undefined); +const getCustomWidgetCreateFollowup = (output: unknown) => { + if (!isSuccessfulCustomWidgetCreateOutput(output)) return []; + const nextAction = output.nextAction; + const hasKnownTarget = + typeof nextAction === "object" && + nextAction !== null && + "targetBoardId" in nextAction && + typeof nextAction.targetBoardId === "string" && + nextAction.targetBoardId.length > 0; + return hasKnownTarget ? (["configure_widget"] as const) : (["configure_widget", "ask_user"] as const); +}; + const getCustomWidgetCreateOutputFromResponseMessages = (responseMessages: readonly AssistantToolResponseMessage[]) => { for (const message of responseMessages.toReversed()) { if (message.role !== "tool" || !Array.isArray(message.content)) continue; @@ -79,7 +109,7 @@ const getCustomWidgetCreateOutputFromResponseMessages = (responseMessages: reado !("type" in part) || part.type !== "tool-result" || !("toolName" in part) || - part.toolName !== "customWidget_create" || + !customWidgetCreateToolNames.has(String(part.toolName)) || !("output" in part) ) { continue; @@ -113,10 +143,8 @@ export const getRequiredAssistantToolNames = ( const latestCreateResult = completedSteps .at(-1) ?.toolResults.toReversed() - .find((result) => result.toolName === "customWidget_create"); - return latestCreateResult && isSuccessfulCustomWidgetCreateOutput(latestCreateResult.output) - ? (["configure_widget", "ask_user"] as const) - : []; + .find((result) => customWidgetCreateToolNames.has(result.toolName)); + return latestCreateResult ? getCustomWidgetCreateFollowup(latestCreateResult.output) : []; } // Approval-gated tools execute immediately before the first model step of the follow-up request. @@ -124,9 +152,7 @@ export const getRequiredAssistantToolNames = ( // UI history, so inspect them before falling back to the persisted UI part. const responseCreateOutput = getCustomWidgetCreateOutputFromResponseMessages(responseMessages); if (responseCreateOutput !== undefined) { - return isSuccessfulCustomWidgetCreateOutput(responseCreateOutput) - ? (["configure_widget", "ask_user"] as const) - : []; + return getCustomWidgetCreateFollowup(responseCreateOutput); } // Approval-gated tools finish in a later HTTP request. At step zero their result is already in @@ -137,14 +163,12 @@ export const getRequiredAssistantToolNames = ( if ( latestToolPart === undefined || latestToolPart.state !== "output-available" || - getToolName(latestToolPart) !== "customWidget_create" + !customWidgetCreateToolNames.has(getToolName(latestToolPart)) ) { return []; } - if (!isSuccessfulCustomWidgetCreateOutput(latestToolPart.output)) return []; - // A successful definition creation always has one structured follow-up: place the widget when // a target is already known, or ask whether it should remain unplaced. Requiring one of these // tools prevents the model from falling back to a prose "Want me to add it?" question. - return ["configure_widget", "ask_user"] as const; + return getCustomWidgetCreateFollowup(latestToolPart.output); }; diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts index 99c2c8cac..485e708e6 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts @@ -2,7 +2,7 @@ import { act, createElement, Fragment } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { AssistantAutoApprovalProvider, @@ -67,6 +67,19 @@ describe("assistant automatic approval tracker", () => { expect(tracker.claim("app-create-1")).toBe(true); }); + test("bounds automatic retries and permits a fresh claim after completion", () => { + const tracker = createAssistantAutoApprovalTracker(2); + + expect(tracker.claim("custom-widget-create-1")).toBe(true); + tracker.release("custom-widget-create-1"); + expect(tracker.claim("custom-widget-create-1")).toBe(true); + tracker.release("custom-widget-create-1"); + expect(tracker.claim("custom-widget-create-1")).toBe(false); + + tracker.complete("custom-widget-create-1"); + expect(tracker.claim("custom-widget-create-1")).toBe(true); + }); + test("keeps the setting for the same local conversation and resets it on a real switch", async () => { const container = document.createElement("div"); containers.push(container); @@ -185,4 +198,48 @@ describe("assistant automatic approval tracker", () => { await act(async () => root.unmount()); }); + + test("retries an approval response that never settles and restores manual controls after two attempts", async () => { + vi.useFakeTimers(); + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + let confirmationCount = 0; + + await act(async () => { + root.render( + createElement( + AssistantAutoApprovalProvider, + { conversationId: "local-thread-1" }, + createElement( + Fragment, + null, + createElement(AutoApprovalProbe), + createElement(AutomaticActionProbe, { + ready: true, + completed: false, + confirm: () => { + confirmationCount += 1; + }, + }), + ), + ), + ); + }); + act(() => currentAutoApproval?.setEnabled(true)); + expect(confirmationCount).toBe(1); + expect(automaticActionInProgress).toBe(true); + + await act(async () => vi.advanceTimersByTime(5_000)); + expect(confirmationCount).toBe(2); + expect(automaticActionInProgress).toBe(true); + + await act(async () => vi.advanceTimersByTime(5_000)); + expect(confirmationCount).toBe(2); + expect(automaticActionInProgress).toBe(false); + + await act(async () => root.unmount()); + vi.useRealTimers(); + }); }); diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx index 02fdbacb9..0b49efe90 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx @@ -3,20 +3,32 @@ import type { PropsWithChildren } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -export const createAssistantAutoApprovalTracker = () => { +const AUTO_APPROVAL_RETRY_TIMEOUT_MS = 5_000; +const AUTO_APPROVAL_MAX_ATTEMPTS = 2; + +export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSITIVE_INFINITY) => { const claimedToolCalls = new Set(); + const attemptsByToolCall = new Map(); return { claim(toolCallId: string) { if (claimedToolCalls.has(toolCallId)) return false; + const attempts = attemptsByToolCall.get(toolCallId) ?? 0; + if (attempts >= maximumAttempts) return false; claimedToolCalls.add(toolCallId); + attemptsByToolCall.set(toolCallId, attempts + 1); return true; }, release(toolCallId: string) { claimedToolCalls.delete(toolCallId); }, + complete(toolCallId: string) { + claimedToolCalls.delete(toolCallId); + attemptsByToolCall.delete(toolCallId); + }, clear() { claimedToolCalls.clear(); + attemptsByToolCall.clear(); }, }; }; @@ -25,6 +37,8 @@ interface AssistantAutoApprovalContextValue { enabled: boolean; setEnabled: (enabled: boolean) => void; requestAction: (toolCallId: string, confirm: () => void) => boolean; + completeAction: (toolCallId: string) => void; + retryRevision: number; } const AssistantAutoApprovalContext = createContext(null); @@ -34,22 +48,41 @@ export const AssistantAutoApprovalProvider = ({ conversationId, }: PropsWithChildren<{ conversationId: string }>) => { const [enabled, setEnabledState] = useState(false); - const trackerRef = useRef(createAssistantAutoApprovalTracker()); + const [retryRevision, setRetryRevision] = useState(0); + const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS)); + const retryTimersRef = useRef(new Map()); const previousConversationIdRef = useRef(conversationId); - const setEnabled = useCallback((nextEnabled: boolean) => { - if (!nextEnabled) trackerRef.current.clear(); - setEnabledState(nextEnabled); + const clearRetryTimer = useCallback((toolCallId: string) => { + const timer = retryTimersRef.current.get(toolCallId); + if (timer !== undefined) window.clearTimeout(timer); + retryTimersRef.current.delete(toolCallId); }, []); + const clearAutomaticActions = useCallback(() => { + for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer); + retryTimersRef.current.clear(); + trackerRef.current.clear(); + }, []); + + const setEnabled = useCallback( + (nextEnabled: boolean) => { + if (!nextEnabled) clearAutomaticActions(); + setEnabledState(nextEnabled); + }, + [clearAutomaticActions], + ); + useEffect(() => { const previousConversationId = previousConversationIdRef.current; previousConversationIdRef.current = conversationId; if (previousConversationId === conversationId) return; - trackerRef.current.clear(); + clearAutomaticActions(); setEnabledState(false); - }, [conversationId]); + }, [clearAutomaticActions, conversationId]); + + useEffect(() => clearAutomaticActions, [clearAutomaticActions]); const requestAction = useCallback( (toolCallId: string, confirm: () => void) => { @@ -57,16 +90,36 @@ export const AssistantAutoApprovalProvider = ({ try { confirm(); + clearRetryTimer(toolCallId); + retryTimersRef.current.set( + toolCallId, + window.setTimeout(() => { + retryTimersRef.current.delete(toolCallId); + trackerRef.current.release(toolCallId); + setRetryRevision((revision) => revision + 1); + }, AUTO_APPROVAL_RETRY_TIMEOUT_MS), + ); return true; } catch { trackerRef.current.release(toolCallId); return false; } }, - [enabled], + [clearRetryTimer, enabled], ); - const value = useMemo(() => ({ enabled, setEnabled, requestAction }), [enabled, requestAction, setEnabled]); + const completeAction = useCallback( + (toolCallId: string) => { + clearRetryTimer(toolCallId); + trackerRef.current.complete(toolCallId); + }, + [clearRetryTimer], + ); + + const value = useMemo( + () => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }), + [completeAction, enabled, requestAction, retryRevision, setEnabled], + ); return {children}; }; @@ -88,20 +141,21 @@ export const useAssistantAutomaticAction = ({ completed: boolean; confirm: () => void; }) => { - const { enabled, requestAction } = useAssistantAutoApproval(); + const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval(); const [inProgress, setInProgress] = useState(false); const confirmRef = useRef(confirm); confirmRef.current = confirm; useEffect(() => { if (!ready || completed || !enabled) { + if (completed) completeAction(toolCallId); setInProgress(false); return; } const requested = requestAction(toolCallId, () => confirmRef.current()); setInProgress(requested); - }, [completed, enabled, ready, requestAction, toolCallId]); + }, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]); return inProgress; }; diff --git a/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts b/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts index 8ab6827ba..96f7c4490 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts @@ -42,6 +42,38 @@ describe("shouldAutomaticallyContinueAssistant", () => { ).toBe(true); }); + test("continues an approved mutation when the same step also reports a provider-executed server tool", () => { + expect( + shouldAutomaticallyContinueAssistant({ + messages: [ + { + id: "assistant-message", + role: "assistant", + parts: [ + { type: "step-start" }, + { + type: "dynamic-tool", + toolName: "openrouter_web_search", + toolCallId: "search-1", + input: { query: "PokéAPI" }, + state: "input-available", + providerExecuted: true, + }, + { + type: "dynamic-tool", + toolName: "customWidget_createFromPreview", + toolCallId: "create-1", + input: { previewSessionId: "preview-1" }, + state: "approval-responded", + approval: { id: "approval-1", approved: true }, + }, + ], + }, + ], + }), + ).toBe(true); + }); + test("waits while a human tool has no result", () => { expect( shouldAutomaticallyContinueAssistant({ diff --git a/apps/nextjs/src/components/assistant/assistant-auto-submit.ts b/apps/nextjs/src/components/assistant/assistant-auto-submit.ts index eacd4914f..a121371a7 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-submit.ts +++ b/apps/nextjs/src/components/assistant/assistant-auto-submit.ts @@ -1,6 +1,35 @@ -import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; +import { + isToolUIPart, + lastAssistantMessageIsCompleteWithApprovalResponses, + lastAssistantMessageIsCompleteWithToolCalls, +} from "ai"; +import type { UIMessage } from "ai"; type AutoSubmitOptions = Parameters[0]; +const lastAssistantMessageHasCompleteLocalApprovalResponses = (messages: UIMessage[]) => { + const message = messages.at(-1); + if (message?.role !== "assistant") return false; + + const lastStepStartIndex = message.parts.reduce( + (lastIndex, part, index) => (part.type === "step-start" ? index : lastIndex), + -1, + ); + const localToolParts = message.parts + .slice(lastStepStartIndex + 1) + .filter(isToolUIPart) + .filter((part) => !part.providerExecuted); + + return ( + localToolParts.some((part) => part.state === "approval-responded") && + localToolParts.every( + (part) => + part.state === "output-available" || part.state === "output-error" || part.state === "approval-responded", + ) + ); +}; + export const shouldAutomaticallyContinueAssistant = (options: AutoSubmitOptions) => - lastAssistantMessageIsCompleteWithApprovalResponses(options) || lastAssistantMessageIsCompleteWithToolCalls(options); + lastAssistantMessageIsCompleteWithApprovalResponses(options) || + lastAssistantMessageHasCompleteLocalApprovalResponses(options.messages) || + lastAssistantMessageIsCompleteWithToolCalls(options); diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 4513d1a81..4f0075398 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -83,7 +83,6 @@ export const AssistantAskUserTool = ({ completed: result !== undefined, confirm: () => { if (!affirmativeResult) return; - setSubmitting(true); addResult(affirmativeResult); }, }); @@ -299,7 +298,6 @@ export const AssistantConfigureAppTool = ({ completed: result !== undefined, confirm: () => { if (!initialValues) return; - setSubmitting(true); addResult(initialValues); }, }); diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index a1c80c8d9..f2c572eee 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -868,7 +868,6 @@ const ToolPart = ({ completed: !awaitingApproval, confirm: () => { respondToApproval({ approved: true, reason: "Approved automatically by the user." }); - setApprovalResponse("approve"); }, }); diff --git a/packages/api/src/router/custom-widget/creation-procedures.ts b/packages/api/src/router/custom-widget/creation-procedures.ts new file mode 100644 index 000000000..8cb134650 --- /dev/null +++ b/packages/api/src/router/custom-widget/creation-procedures.ts @@ -0,0 +1,110 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod/v4"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { + customWidgetCreateSchema, + customWidgetDefinitionSchema, + normalizeCustomWidgetAuthoringDefinition, +} from "@homarr/custom-widgets/core"; + +import { permissionRequiredProcedure } from "../../trpc"; +import { parseCustomWidgetAuthoringInput } from "./authoring-validation"; +import { insertCustomWidgetDefinition } from "./definition-insert"; +import { getPreviewJournal, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions"; +import { assertSecretSources } from "./secret-policy"; + +const logger = createLogger({ module: "custom-widget" }); + +const getCreatedCustomWidgetResult = (id: string, targetBoardId?: string) => ({ + id, + managementPath: `/manage/custom-widgets/edit/${id}`, + nextAction: { + type: "place-custom-widget" as const, + widgetKind: "customApi" as const, + options: { definitionId: id }, + ...(targetBoardId ? { targetBoardId } : {}), + whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.", + whenTargetIsUnknown: + "Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.", + }, +}); + +export const creationProcedures = { + create: permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: + "Create a Custom JSX widget only after customWidget_validate, customWidget_previewCreate, and every returned preview query succeed. Prefer templateLines for multiline JSX. Returns a client-navigable edit link.", + }, + }) + .input(customWidgetCreateSchema) + .mutation(async ({ ctx, input }) => { + const { secrets, ...candidate } = input; + const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate)); + assertSecretSources(definition.sources, secrets); + const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets); + logger.info("Created custom widget definition", { id, name: definition.name }); + return getCreatedCustomWidgetResult(id); + }), + + createFromPreview: permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: + "Persist the exact tested definition from a customWidget_previewCreate session. Prefer this over resending a large widget to customWidget_create. Every query in the final preview must have a successful customWidget_previewQuery journal entry.", + }, + }) + .input(z.object({ previewSessionId: z.string().min(1), targetBoardId: z.string().min(1).optional() })) + .mutation(async ({ ctx, input }) => { + const session = await getPreviewSession(input.previewSessionId, ctx.session.user.id); + const journal = await getPreviewJournal(session.id, ctx.session.user.id); + const unverifiedQueryIds = Object.entries(session.requests).flatMap(([requestId, request]) => { + if (request.kind !== "query") return []; + const verified = journal.some( + (entry) => + entry.kind === "query" && + entry.requestId === requestId && + entry.sessionRevision === session.revision && + entry.status !== null && + entry.status >= 200 && + entry.status < 300, + ); + return verified ? [] : [requestId]; + }); + if (unverifiedQueryIds.length > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Test every final preview query successfully before creating the widget: ${unverifiedQueryIds.join(", ")}`, + }); + } + + const definition = parseCustomWidgetAuthoringInput(() => + customWidgetDefinitionSchema.parse({ + $schema: "homarr-custom-widget-v2", + name: session.name, + description: session.description, + iconUrl: session.iconUrl, + sources: session.sources, + requests: session.requests, + options: session.optionDefinitions, + template: session.template, + }), + ); + const secrets = Object.keys(session.sources).flatMap((sourceId) => + getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })), + ); + assertSecretSources(definition.sources, secrets); + const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets); + logger.info("Created custom widget definition from tested preview", { + id, + name: definition.name, + previewSessionId: session.id, + }); + return getCreatedCustomWidgetResult(id, input.targetBoardId); + }), +}; diff --git a/packages/api/src/router/custom-widget/metadata-procedures.ts b/packages/api/src/router/custom-widget/metadata-procedures.ts index 3727d7fe1..aa6af6805 100644 --- a/packages/api/src/router/custom-widget/metadata-procedures.ts +++ b/packages/api/src/router/custom-widget/metadata-procedures.ts @@ -108,7 +108,8 @@ export const metadataProcedures = { .query(({ input }) => { const example = getCustomWidgetExample(input.name); if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" }); - return example; + const { template, ...widget } = example.widget; + return { ...example, widget: { ...widget, templateLines: template.split("\n") } }; }), validate: permissionRequiredProcedure diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts index a1eeff2cc..26c4a40a7 100644 --- a/packages/api/src/router/custom-widget/preview-base-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts @@ -109,6 +109,8 @@ const previewCreateProcedure = permissionRequiredProcedure sources: definition.sources, requests: definition.requests, name: definition.name, + description: definition.description, + iconUrl: definition.iconUrl, template: definition.template, optionDefinitions: definition.options, options, @@ -145,6 +147,8 @@ export const previewBaseProcedures = { return { id: session.id, name: session.name, + description: session.description, + iconUrl: session.iconUrl, expiresAt: session.expiresAt, template: session.template, optionDefinitions: session.optionDefinitions, diff --git a/packages/api/src/router/custom-widget/preview-sessions.ts b/packages/api/src/router/custom-widget/preview-sessions.ts index 289247213..7a80c097a 100644 --- a/packages/api/src/router/custom-widget/preview-sessions.ts +++ b/packages/api/src/router/custom-widget/preview-sessions.ts @@ -95,8 +95,8 @@ export const setPreviewSessionLiveActions = (id: string, userId: string, enabled call(() => getService().setLiveActions(id, userId, enabled)); export const appendPreviewJournal = ( session: CustomWidgetPreviewSession, - entry: Omit, -) => call(() => getService().appendJournal(session, entry)); + entry: Omit, +) => call(() => getService().appendJournal(session, { ...entry, sessionRevision: session.revision })); export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId)); export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) => getService().getSecrets(session, sourceId); diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts index 35f23b532..da9b78358 100644 --- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts +++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts @@ -15,6 +15,11 @@ import { describe, expect, test } from "vitest"; import { customWidgetRouter } from "../../custom-widget/custom-widget-router"; import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests"; import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence"; +import { + appendPreviewJournal, + configurePreviewSessionSource, + getPreviewSession, +} from "../../custom-widget/preview-sessions"; import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition"; const userId = createId(); @@ -33,6 +38,8 @@ const nonAdminSession = { const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget; if (!jellyfin) throw new Error("Jellyfin bundled widget is missing"); +const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget; +if (!pokedex) throw new Error("Pokédex bundled widget is missing"); const jellyfinDefaultSource = jellyfin.sources.default; if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing"); @@ -142,6 +149,69 @@ describe("custom widget definition persistence", () => { expect((await caller.get({ id: created.id })).template).toBe(template); }); + test("persists the exact final tested Pokédex preview without resending its JSX", async () => { + const db = await prepareDatabase(); + const caller = createCaller(db); + const { template, ...definition } = pokedex; + const candidate = { ...definition, templateLines: template.split("\n") }; + + // A long-running authoring request can iterate several times. Only the final preview session + // is eligible for persistence, and its compact ID replaces a second large streamed payload. + await caller.previewCreate({ definition: candidate, secrets: [] }); + await caller.previewCreate({ definition: candidate, secrets: [] }); + const finalPreview = await caller.previewCreate({ definition: candidate, secrets: [] }); + const createInput = { previewSessionId: finalPreview.previewSession.id, targetBoardId: "board-hey" }; + expect(Buffer.byteLength(JSON.stringify(createInput), "utf8")).toBeLessThan(128); + + await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully"); + + const previewSession = await getPreviewSession(finalPreview.previewSession.id, userId); + for (const [requestId, request] of Object.entries(previewSession.requests)) { + if (request.kind !== "query") continue; + await appendPreviewJournal(previewSession, { + requestId, + kind: "query", + method: request.method, + path: request.path, + status: 200, + durationMs: 1, + simulated: false, + }); + } + + const defaultSource = previewSession.sources.default; + if (!defaultSource) throw new Error("Pokédex preview source is missing"); + await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []); + await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully"); + + const finalPreviewSession = await getPreviewSession(finalPreview.previewSession.id, userId); + for (const [requestId, request] of Object.entries(finalPreviewSession.requests)) { + if (request.kind !== "query") continue; + await appendPreviewJournal(finalPreviewSession, { + requestId, + kind: "query", + method: request.method, + path: request.path, + status: 200, + durationMs: 1, + simulated: false, + }); + } + + const created = await caller.createFromPreview(createInput); + expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`); + expect(created.nextAction.options).toEqual({ definitionId: created.id }); + expect(created.nextAction.targetBoardId).toBe("board-hey"); + await expect(caller.get({ id: created.id })).resolves.toMatchObject({ + name: pokedex.name, + description: pokedex.description, + template, + sources: pokedex.sources, + requests: pokedex.requests, + options: pokedex.options, + }); + }); + test("updates a saved widget with MCP-friendly multiline templateLines", async () => { const db = await prepareDatabase(); const caller = createCaller(db); diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index 834f55d29..774124f2d 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -129,7 +129,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release. -Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_create. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate, preview, and create tool inputs. +Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs. Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions. diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index 2d6d4ca41..186308ef4 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -3,6 +3,7 @@ import { customJsxCatalogGlobalPropByName, resolveCustomJsxPropDescriptor, } from "./component-catalog"; +import { BUNDLED_CUSTOM_WIDGETS } from "./bundled-widgets"; import { customJsxExamples } from "./examples"; import { customJsxTablerIconNames } from "./tabler-icons"; @@ -11,7 +12,7 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL = "https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget"; export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND = "npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget"; -export const CUSTOM_WIDGET_SKILL_VERSION = "2.2.0"; +export const CUSTOM_WIDGET_SKILL_VERSION = "2.3.0"; export const CUSTOM_WIDGET_SKILL_REFERENCES = { "references/schema.md": `# Schema @@ -77,7 +78,7 @@ Author one widget at a time. When connected to Homarr, retrieve \`homarr://custo 2. Create one credential-free widget with keyed \`sources\`, keyed \`requests\`, optional keyed \`options\`, and safe JSX \`template\`. 3. Validate, preview, test queries and simulated actions, visually inspect, and iterate. 4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext. -5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. +5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with \`customWidget_createFromPreview\`; do not resend a large template through \`customWidget_create\` unless no preview session is available. Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`. @@ -101,6 +102,8 @@ const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [ ), ].join(""); +const pokedexAuthoringExample = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex"); + export function getCustomWidgetSkill() { return { name: "homarr-custom-widget", @@ -135,7 +138,19 @@ export function getCustomWidgetComponentCatalog() { } export function getCustomWidgetExampleCatalog() { - return customJsxExamples.map(({ id, title, description }) => ({ id, title, description })); + return [ + ...customJsxExamples.map(({ id, title, description }) => ({ id, title, description })), + ...(pokedexAuthoringExample + ? [ + { + id: "pokedex", + title: "Complete Pokédex", + description: + "A production-safe PokéAPI browser with searchable species, artwork, types, abilities, base stats, loading states, and manual detail requests.", + }, + ] + : []), + ]; } export function getCustomWidgetComponent(name: string) { @@ -183,5 +198,14 @@ export function getCustomWidgetSharedProps(names: readonly string[]) { } export function getCustomWidgetExample(name: string) { + if (name === "pokedex" && pokedexAuthoringExample) { + return { + id: "pokedex", + title: "Complete Pokédex", + description: + "A production-safe PokéAPI browser with searchable species, artwork, types, abilities, base stats, loading states, and manual detail requests.", + widget: pokedexAuthoringExample.widget, + }; + } return customJsxExamples.find((example) => example.id === name) ?? null; } diff --git a/packages/custom-widgets/src/server/preview-sessions.ts b/packages/custom-widgets/src/server/preview-sessions.ts index 10c617a90..921c44b89 100644 --- a/packages/custom-widgets/src/server/preview-sessions.ts +++ b/packages/custom-widgets/src/server/preview-sessions.ts @@ -29,6 +29,8 @@ const sessionSchema = z.object({ secrets: z.array(encryptedSecretSchema), requests: customWidgetRequestsSchema, name: z.string(), + description: z.string().optional(), + iconUrl: z.string().optional(), template: z.string(), optionDefinitions: customWidgetOptionsSchema, options: z.record(z.string(), z.unknown()), @@ -46,6 +48,7 @@ const journalEntrySchema = z.object({ status: z.number().nullable(), durationMs: z.number().nonnegative(), simulated: z.boolean(), + sessionRevision: z.number().int().nonnegative(), timestamp: z.number(), }); export type CustomWidgetPreviewJournalEntry = z.infer; @@ -56,6 +59,8 @@ export interface CreatePreviewSessionInput { secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>; requests: Record; name: string; + description?: string; + iconUrl?: string; template: string; optionDefinitions: CustomWidgetOptions; options: Record; @@ -105,6 +110,8 @@ export class CustomWidgetPreviewSessionService { secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })), requests: input.requests, name: input.name, + description: input.description, + iconUrl: input.iconUrl, template: input.template, optionDefinitions: input.optionDefinitions, options: input.options, diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts index 298633f5f..c9bb9426f 100644 --- a/packages/custom-widgets/src/test/ai-prompt.spec.ts +++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts @@ -45,6 +45,7 @@ describe("AI prompt", () => { 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_createFromPreview"); 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( diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 3417b84a0..0760e2c4d 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { getCustomWidgetComponent, getCustomWidgetComponentCatalog, + getCustomWidgetExample, getCustomWidgetSharedProps, getCustomWidgetSkill, getCustomWidgetSkillContent, @@ -15,7 +16,7 @@ const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.age describe("authoring resources", () => { 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.2.0" }); + expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.3.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"); @@ -40,6 +41,17 @@ describe("authoring resources", () => { expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]), ); expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })])); + expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "pokedex" })])); + const pokedex = getCustomWidgetExample("pokedex"); + expect(pokedex).toMatchObject({ + id: "pokedex", + widget: { + $schema: "homarr-custom-widget-v2", + name: "Pokédex", + sources: { default: { baseUrl: "https://pokeapi.co" } }, + }, + }); + expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000); expect(catalog.sharedProps).toMatchObject({ count: expect.any(Number), fetchTool: "customWidget_getSharedProps", diff --git a/packages/custom-widgets/src/test/preview-sessions.spec.ts b/packages/custom-widgets/src/test/preview-sessions.spec.ts index e9b038876..75304f51d 100644 --- a/packages/custom-widgets/src/test/preview-sessions.spec.ts +++ b/packages/custom-widgets/src/test/preview-sessions.spec.ts @@ -64,8 +64,13 @@ describe("preview sessions", () => { status: 200, durationMs: 5, simulated: false, + sessionRevision: session.revision, + }); + expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ + path: "/data", + status: 200, + sessionRevision: session.revision, }); - expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 }); }); it("applies source deployment values without restoring stale source metadata", async () => {