diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index f54886bb3..a572b63a2 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -135,13 +135,17 @@ Common collection and widget results are summarized inline as compact responsive structured result remains available in the tool card details. The shield control in the conversation header can enable automatic approvals for the current conversation. -While enabled, Homarr approves protected mutation calls as they arrive. Human questions and editable review -forms still wait for input. Automatic approvals turn off when you switch conversations, and the shared -approval tracker prevents the same call from being approved twice when both a board widget and the floating -assistant are visible. +While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved, +presented app details and board changes are used, and structured questions with an explicitly categorized +**Confirm** answer choose that answer. Neutral choices such as selecting a board still wait for input because +they do not have a safe affirmative default. Automatic approvals turn off when you switch conversations, and +the shared approval tracker prevents the same call from being handled twice when both a board widget and the +floating assistant are visible. When the assistant genuinely needs missing information or a choice, it can pause with a structured -question. Users can select one of the proposed answers or choose **Other** and enter their own response. +question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect +of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and +enter their own response. Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the question or approval that needs attention and opens directly back to the request. While an answer, app form, or mutation approval is pending, the composer preserves its draft but does not replace the pending diff --git a/apps/docs/docs/widgets/assistant/index.mdx b/apps/docs/docs/widgets/assistant/index.mdx index 1a0a2511f..be481f1dc 100644 --- a/apps/docs/docs/widgets/assistant/index.mdx +++ b/apps/docs/docs/widgets/assistant/index.mdx @@ -45,7 +45,7 @@ active conversation silently: select **Open conversation** inside the widget bef - Mention permitted apps, integrations, boards, widgets, and tools with `@`. - Use `/` shortcuts for common dashboard tasks. - Review tool calls, approve mutations, and answer structured assistant questions inline. -- Enable conversation-scoped automatic approvals from the shield control when you want protected mutations to run without individual approval clicks. +- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks. - Edit user messages and copy, regenerate, branch, quote, export, or rate responses. - Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing, speed, provider routing, and cost. diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index ef84df1c2..74abb683c 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -94,7 +94,7 @@ Homarr concepts: Action rules: - Prefer read-only tools before actions. -- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose. Offer distinct choices and leave the freeform Other answer enabled. +- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. - Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears. - A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call. - Do not retry a denied action. diff --git a/apps/nextjs/src/components/assistant/assistant-ask-user.spec.ts b/apps/nextjs/src/components/assistant/assistant-ask-user.spec.ts new file mode 100644 index 000000000..ba32e4bff --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-ask-user.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; + +import { + getAssistantAffirmativeOption, + getAssistantAffirmativeResult, + getAssistantAskUserOptionKind, +} from "./assistant-ask-user"; + +describe("assistant structured choice categories", () => { + test("uses the explicit category instead of button order or wording", () => { + const options = [ + { id: "home", label: "Home", kind: "alternative" as const }, + { id: "continue", label: "Use the proposed board", kind: "affirmative" as const }, + { id: "cancel", label: "Keep the current board", kind: "negative" as const }, + ]; + + expect(getAssistantAffirmativeOption(options)).toEqual(options[1]); + expect(getAssistantAffirmativeResult(options)).toEqual({ + answer: "Use the proposed board", + optionId: "continue", + optionKind: "affirmative", + source: "option", + }); + }); + + test("does not guess among neutral selections", () => { + expect( + getAssistantAffirmativeOption([ + { id: "home", label: "Home", kind: "alternative" }, + { id: "media", label: "Media", kind: "alternative" }, + ]), + ).toBeUndefined(); + }); + + test("recognizes affirmative and negative legacy choices", () => { + expect(getAssistantAskUserOptionKind({ id: "yes", label: "Yes, create it" })).toBe("affirmative"); + expect(getAssistantAskUserOptionKind({ id: "no", label: "No, keep it" })).toBe("negative"); + expect(getAssistantAskUserOptionKind({ id: "home", label: "Home" })).toBe("alternative"); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-ask-user.ts b/apps/nextjs/src/components/assistant/assistant-ask-user.ts new file mode 100644 index 000000000..22bb0da55 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-ask-user.ts @@ -0,0 +1,37 @@ +import type { AskUserArgs, AskUserResult, AssistantAskUserOptionKind } from "./assistant-tool-contracts"; + +export type AssistantAskUserOption = AskUserArgs["options"][number]; + +const affirmativeTokens = new Set(["accept", "agree", "allow", "approve", "confirm", "continue", "proceed", "yes"]); +const negativeTokens = new Set(["cancel", "decline", "deny", "disagree", "no", "reject", "stop"]); + +const inferOptionKind = (option: Pick): AssistantAskUserOptionKind => { + const tokens = `${option.id} ${option.label}` + .toLocaleLowerCase("en") + .split(/[^a-z]+/u) + .filter(Boolean); + if (tokens.some((token) => affirmativeTokens.has(token))) return "affirmative"; + if (tokens.some((token) => negativeTokens.has(token))) return "negative"; + return "alternative"; +}; + +export const getAssistantAskUserOptionKind = ( + option: Pick & { kind?: AssistantAskUserOptionKind }, +) => option.kind ?? inferOptionKind(option); + +export const getAssistantAffirmativeOption = (options: readonly AssistantAskUserOption[]) => + options.find((option) => getAssistantAskUserOptionKind(option) === "affirmative"); + +export const getAssistantAffirmativeResult = ( + options: readonly AssistantAskUserOption[], +): AskUserResult | undefined => { + const option = getAssistantAffirmativeOption(options); + if (!option) return undefined; + + return { + answer: option.label, + optionId: option.id, + optionKind: "affirmative", + source: "option", + }; +}; 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 794c38148..99c2c8cac 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, createElement } from "react"; +import { act, createElement, Fragment } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, test } from "vitest"; @@ -8,12 +8,14 @@ import { AssistantAutoApprovalProvider, createAssistantAutoApprovalTracker, useAssistantAutoApproval, + useAssistantAutomaticAction, } from "./assistant-auto-approval"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type AutoApproval = ReturnType; let currentAutoApproval: AutoApproval | null = null; +let automaticActionInProgress = false; const containers: HTMLDivElement[] = []; const AutoApprovalProbe = () => { @@ -21,8 +23,27 @@ const AutoApprovalProbe = () => { return null; }; +const AutomaticActionProbe = ({ + ready, + completed, + confirm, +}: { + ready: boolean; + completed: boolean; + confirm: () => void; +}) => { + automaticActionInProgress = useAssistantAutomaticAction({ + toolCallId: "configure-app-1", + ready, + completed, + confirm, + }); + return null; +}; + afterEach(() => { currentAutoApproval = null; + automaticActionInProgress = false; for (const container of containers.splice(0)) container.remove(); }); @@ -87,12 +108,12 @@ describe("assistant automatic approval tracker", () => { }); act(() => currentAutoApproval?.setEnabled(true)); - expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true); - expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(false); + expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true); + expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(false); act(() => currentAutoApproval?.setEnabled(false)); act(() => currentAutoApproval?.setEnabled(true)); - expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true); + expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true); await act(async () => root.unmount()); }); @@ -115,11 +136,52 @@ describe("assistant automatic approval tracker", () => { act(() => currentAutoApproval?.setEnabled(true)); expect( - currentAutoApproval?.requestApproval("app-create-1", () => { + currentAutoApproval?.requestAction("app-create-1", () => { throw new Error("Approval transport failed"); }), ).toBe(false); - expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true); + expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true); + + await act(async () => root.unmount()); + }); + + test("confirms a ready human-tool action exactly once and clears progress on completion", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + let confirmationCount = 0; + const confirm = () => { + confirmationCount += 1; + }; + const renderAction = async (ready: boolean, completed: boolean) => { + await act(async () => { + root.render( + createElement( + AssistantAutoApprovalProvider, + { conversationId: "local-thread-1" }, + createElement( + Fragment, + null, + createElement(AutoApprovalProbe), + createElement(AutomaticActionProbe, { ready, completed, confirm }), + ), + ), + ); + }); + }; + + await renderAction(false, false); + act(() => currentAutoApproval?.setEnabled(true)); + await renderAction(true, false); + expect(confirmationCount).toBe(1); + expect(automaticActionInProgress).toBe(true); + + await renderAction(true, false); + expect(confirmationCount).toBe(1); + + await renderAction(false, true); + expect(automaticActionInProgress).toBe(false); await act(async () => root.unmount()); }); diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx index 69675396c..02fdbacb9 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx @@ -24,7 +24,7 @@ export const createAssistantAutoApprovalTracker = () => { interface AssistantAutoApprovalContextValue { enabled: boolean; setEnabled: (enabled: boolean) => void; - requestApproval: (toolCallId: string, approve: () => void) => boolean; + requestAction: (toolCallId: string, confirm: () => void) => boolean; } const AssistantAutoApprovalContext = createContext(null); @@ -51,12 +51,12 @@ export const AssistantAutoApprovalProvider = ({ setEnabledState(false); }, [conversationId]); - const requestApproval = useCallback( - (toolCallId: string, approve: () => void) => { + const requestAction = useCallback( + (toolCallId: string, confirm: () => void) => { if (!enabled || !trackerRef.current.claim(toolCallId)) return false; try { - approve(); + confirm(); return true; } catch { trackerRef.current.release(toolCallId); @@ -66,7 +66,7 @@ export const AssistantAutoApprovalProvider = ({ [enabled], ); - const value = useMemo(() => ({ enabled, setEnabled, requestApproval }), [enabled, requestApproval, setEnabled]); + const value = useMemo(() => ({ enabled, setEnabled, requestAction }), [enabled, requestAction, setEnabled]); return {children}; }; @@ -76,3 +76,32 @@ export const useAssistantAutoApproval = () => { if (value === null) throw new Error("useAssistantAutoApproval must be used within AssistantAutoApprovalProvider"); return value; }; + +export const useAssistantAutomaticAction = ({ + toolCallId, + ready, + completed, + confirm, +}: { + toolCallId: string; + ready: boolean; + completed: boolean; + confirm: () => void; +}) => { + const { enabled, requestAction } = useAssistantAutoApproval(); + const [inProgress, setInProgress] = useState(false); + const confirmRef = useRef(confirm); + confirmRef.current = confirm; + + useEffect(() => { + if (!ready || completed || !enabled) { + setInProgress(false); + return; + } + + const requested = requestAction(toolCallId, () => confirmRef.current()); + setInProgress(requested); + }, [completed, enabled, ready, requestAction, toolCallId]); + + return inProgress; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-automatic-action-progress.tsx b/apps/nextjs/src/components/assistant/assistant-automatic-action-progress.tsx new file mode 100644 index 000000000..4c993d750 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-automatic-action-progress.tsx @@ -0,0 +1,12 @@ +import { Group, Loader, Text } from "@mantine/core"; + +import classes from "./assistant-panel.module.css"; + +export const AssistantAutomaticActionProgress = ({ label }: { label: string }) => ( + + + + {label} + + +); diff --git a/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx index 4c71874ec..d381409d7 100644 --- a/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx @@ -44,10 +44,13 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; import { + getAssistantBoardSettingsResult, getAssistantBoardSettingsDefaultTab, getChangedBoardSettings, getCustomCssWarnings, } from "./assistant-board-settings"; +import { useAssistantAutomaticAction } from "./assistant-auto-approval"; +import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status"; import { AssistantHumanToolError } from "./assistant-human-tools"; import classes from "./assistant-panel.module.css"; @@ -92,6 +95,7 @@ export const AssistantConfigureBoardSettingsTool = ({ result, addResult, status, + toolCallId, }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant.configureBoardSettings"); const hasCompleteArguments = hasCompleteAssistantToolArguments(status); @@ -100,6 +104,17 @@ export const AssistantConfigureBoardSettingsTool = ({ { id: boardId }, { enabled: result === undefined && hasCompleteArguments && boardId.length > 0, retry: false }, ); + const autoConfirming = useAssistantAutomaticAction({ + toolCallId, + ready: result === undefined && settings.data !== undefined && hasCompleteArguments, + completed: result !== undefined, + confirm: () => { + if (!settings.data || !args) return; + const currentValues = toCurrentValues(settings.data); + const proposedValues = normalizeFormValues(settings.data, args.changes); + addResult(getAssistantBoardSettingsResult(settings.data.id, currentValues, proposedValues)); + }, + }); if (result) { const cancelled = "cancelled" in result && result.cancelled; @@ -144,6 +159,14 @@ export const AssistantConfigureBoardSettingsTool = ({ if (!settings.data) return ; + if (autoConfirming) { + return ( + + + + ); + } + return ( { customCss: "", }); }); + + test("builds the exact result used by automatic board approval", () => { + expect( + getAssistantBoardSettingsResult( + "board-1", + { pageTitle: "Home", primaryColor: "#112233" }, + { pageTitle: "Home", primaryColor: "#445566" }, + ), + ).toEqual({ id: "board-1", primaryColor: "#445566" }); + expect(getAssistantBoardSettingsResult("board-1", { pageTitle: "Home" }, { pageTitle: "Home" })).toEqual({ + id: "board-1", + cancelled: true, + }); + }); }); describe("getCustomCssWarnings", () => { diff --git a/apps/nextjs/src/components/assistant/assistant-board-settings.ts b/apps/nextjs/src/components/assistant/assistant-board-settings.ts index a939159fa..03d99d9b5 100644 --- a/apps/nextjs/src/components/assistant/assistant-board-settings.ts +++ b/apps/nextjs/src/components/assistant/assistant-board-settings.ts @@ -1,4 +1,4 @@ -import type { AssistantBoardSettingsChanges } from "./assistant-tool-contracts"; +import type { AssistantBoardSettingsChanges, ConfigureBoardSettingsResult } from "./assistant-tool-contracts"; export const assistantBoardSettingKeys = [ "pageTitle", @@ -27,6 +27,15 @@ export const getChangedBoardSettings = (current: AssistantBoardSettingsChanges, ), ) as AssistantBoardSettingsChanges; +export const getAssistantBoardSettingsResult = ( + id: string, + current: AssistantBoardSettingsChanges, + next: AssistantBoardSettingsChanges, +): ConfigureBoardSettingsResult => { + const changes = getChangedBoardSettings(current, next); + return Object.keys(changes).length === 0 ? { id, cancelled: true } : { id, ...changes }; +}; + export const getCustomCssWarnings = (css: string) => ({ importsStylesheet: /@import\b/iu.test(css), loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css), diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 5b3b8bf92..d0bc7c534 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -2,14 +2,24 @@ import { useEffect, useRef, useState } from "react"; import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } from "@assistant-ui/react"; -import { Alert, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; -import { IconAlertTriangle, IconCheck, IconMessageQuestion, IconPencil, IconX } from "@tabler/icons-react"; +import { Alert, Badge, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; +import { + IconAlertTriangle, + IconArrowRight, + IconCheck, + IconMessageQuestion, + IconPencil, + IconX, +} from "@tabler/icons-react"; import type { z } from "zod/v4"; import { AppForm } from "@homarr/forms-collection"; import { useScopedI18n } from "@homarr/translation/client"; import type { appManageSchema } from "@homarr/validation/app"; +import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user"; +import { useAssistantAutomaticAction } from "./assistant-auto-approval"; +import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import classes from "./assistant-panel.module.css"; import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status"; import { normalizeAssistantAppIconUrl } from "./assistant-tool-contracts"; @@ -44,6 +54,7 @@ export const AssistantAskUserTool = ({ result, addResult, status, + toolCallId, }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant.askUser"); const [showOther, setShowOther] = useState(false); @@ -51,6 +62,18 @@ export const AssistantAskUserTool = ({ const [submitting, setSubmitting] = useState(false); const otherInputRef = useRef(null); const options = Array.isArray(args?.options) ? args.options : []; + const affirmativeResult = getAssistantAffirmativeResult(options); + const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2; + const autoConfirming = useAssistantAutomaticAction({ + toolCallId, + ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined, + completed: result !== undefined, + confirm: () => { + if (!affirmativeResult) return; + setSubmitting(true); + addResult(affirmativeResult); + }, + }); useEffect(() => { if (showOther) otherInputRef.current?.focus(); @@ -99,7 +122,7 @@ export const AssistantAskUserTool = ({ const submitOther = () => { const answer = other.trim(); if (!answer) return; - submitResult({ answer, source: "other" }); + submitResult({ answer, optionKind: "alternative", source: "other" }); }; return ( @@ -121,31 +144,58 @@ export const AssistantAskUserTool = ({ - {options.map((option, index) => ( - - ))} + {autoConfirming ? ( + + ) : ( + options.map((option, index) => { + const kind = getAssistantAskUserOptionKind(option); + const icon = + kind === "affirmative" ? ( + + ) : kind === "negative" ? ( + + ) : ( + + ); + const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; - {args.allowOther !== false && + return ( + + ); + }) + )} + + {!autoConfirming && + args.allowOther !== false && (showOther ? ( } + rightSection={ + + {t("optionKind.custom")} + + } justify="flex-start" onClick={() => setShowOther(true)} > @@ -206,6 +261,16 @@ export const AssistantConfigureAppTool = ({ const t = useScopedI18n("common.assistant.configureApp"); const [submitting, setSubmitting] = useState(false); const initialValues = getAssistantAppFormValues(args, status); + const autoConfirming = useAssistantAutomaticAction({ + toolCallId, + ready: result === undefined && initialValues !== null, + completed: result !== undefined, + confirm: () => { + if (!initialValues) return; + setSubmitting(true); + addResult(initialValues); + }, + }); if (result) { return ( @@ -241,6 +306,14 @@ export const AssistantConfigureAppTool = ({ ); } + if (autoConfirming) { + return ( + + + + ); + } + return ( diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 085d2976a..6760c1e28 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -100,7 +100,7 @@ import { assistantReasoningModes } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; -import { useAssistantAutoApproval } from "./assistant-auto-approval"; +import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; import type { AssistantPendingAction } from "./assistant-pending-action"; @@ -354,11 +354,8 @@ const ToolPart = ({ timing, }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant"); - const autoApproval = useAssistantAutoApproval(); const [opened, setOpened] = useState(false); const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null); - const [autoApprovalInProgress, setAutoApprovalInProgress] = useState(false); - const autoApprovalAttemptRef = useRef(null); const completed = status?.type === "complete"; const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution; const denied = approval?.approved === false; @@ -369,26 +366,15 @@ const ToolPart = ({ (typeof result === "object" && result !== null && "error" in result)); const successful = completed && !denied && !failed; const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined; - - useEffect(() => { - if (!awaitingApproval) { - autoApprovalAttemptRef.current = null; - setAutoApprovalInProgress(false); - return; - } - if (!autoApproval.enabled) { - setAutoApprovalInProgress(false); - return; - } - if (autoApprovalAttemptRef.current === toolCallId) return; - autoApprovalAttemptRef.current = toolCallId; - - const requested = autoApproval.requestApproval(toolCallId, () => { + const autoApprovalInProgress = useAssistantAutomaticAction({ + toolCallId, + ready: awaitingApproval, + completed: !awaitingApproval, + confirm: () => { respondToApproval({ approved: true, reason: "Approved automatically by the user." }); setApprovalResponse("approve"); - }); - setAutoApprovalInProgress(requested); - }, [autoApproval, awaitingApproval, respondToApproval, toolCallId]); + }, + }); return ( diff --git a/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts b/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts index b9414bc74..b96a8abb8 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-contracts.spec.ts @@ -7,8 +7,8 @@ const supportedToolArguments = { ask_user: { question: "How should the app be added?", options: [ - { id: "yes", label: "Yes" }, - { id: "no", label: "No" }, + { id: "yes", label: "Yes", kind: "affirmative" }, + { id: "no", label: "No", kind: "negative" }, ], }, configure_app: { @@ -45,9 +45,14 @@ describe("assistant human tool contracts", () => { browserToolContracts.ask_user.parameters.safeParse({ question: "How should the app be added?", options: [ - { id: "yes", label: "Yes" }, - { id: "no", label: "No" }, - { id: "alternative", label: "Choose an alternative", description: "Adjust the proposed app first." }, + { id: "yes", label: "Yes", kind: "affirmative" }, + { id: "no", label: "No", kind: "negative" }, + { + id: "alternative", + label: "Choose an alternative", + description: "Adjust the proposed app first.", + kind: "alternative", + }, ], }).success, ).toBe(true); @@ -57,7 +62,19 @@ describe("assistant human tool contracts", () => { expect( browserToolContracts.ask_user.parameters.safeParse({ question: "Continue?", - options: [{ id: "yes", label: "Yes" }], + options: [{ id: "yes", label: "Yes", kind: "affirmative" }], + }).success, + ).toBe(false); + }); + + test("requires every structured choice to declare its category", () => { + expect( + browserToolContracts.ask_user.parameters.safeParse({ + question: "Continue?", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], }).success, ).toBe(false); }); diff --git a/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts b/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts index c8282b876..49639cf84 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-contracts.ts @@ -2,10 +2,18 @@ import { z } from "zod/v4"; import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; +export const assistantAskUserOptionKinds = ["affirmative", "negative", "alternative"] as const; +export type AssistantAskUserOptionKind = (typeof assistantAskUserOptionKinds)[number]; + const askUserOptionSchema = z.object({ id: z.string().trim().min(1).max(48), label: z.string().trim().min(1).max(80), description: z.string().trim().max(180).optional(), + kind: z + .enum(assistantAskUserOptionKinds) + .describe( + "Classify agreement, approval, or proceeding as affirmative; refusal or stopping as negative; and unrelated selections as alternative.", + ), }); const boardImageValueSchema = z.string().trim().max(2_048).nullable(); @@ -41,7 +49,7 @@ export const assistantBoardSettingsChangesSchema = z export const browserToolContracts = { ask_user: { description: - "Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options; the UI adds a freeform Other choice when allowOther is not false.", + "Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.", parameters: z.object({ question: z.string().trim().min(1).max(240), description: z.string().trim().max(400).optional(), @@ -98,6 +106,7 @@ export type AskUserArgs = z.infer<(typeof browserToolContracts)["ask_user"]["par export type AskUserResult = { answer: string; optionId?: string; + optionKind?: AssistantAskUserOptionKind; source: "option" | "other"; }; export type ConfigureAppArgs = z.infer<(typeof browserToolContracts)["configure_app"]["parameters"]>; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index d9e218b07..a58106edc 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1688,14 +1688,22 @@ "otherLabel": "Your answer", "otherPlaceholder": "Type another answer…", "submit": "Send answer", - "cancelOther": "Cancel other answer" + "cancelOther": "Cancel other answer", + "automaticAffirmative": "Choosing the affirmative answer automatically", + "optionKind": { + "affirmative": "Confirm", + "negative": "Decline", + "alternative": "Option", + "custom": "Custom" + } }, "configureApp": { "title": "Review app details", "description": "Choose a local icon and adjust anything before the assistant requests creation approval.", "preparing": "Preparing app details", "continue": "Use these details", - "ready": "App details ready" + "ready": "App details ready", + "automaticContinue": "Using these app details automatically" }, "configureBoardSettings": { "title": "Review board changes", @@ -1710,6 +1718,7 @@ "noChanges": "No settings will change", "keepCurrent": "Keep current settings", "continue": "Continue to approval", + "automaticContinue": "Accepting the proposed board changes automatically", "resetAll": "Reset all changes", "tabs": { "css": "Custom CSS", @@ -1737,7 +1746,7 @@ "autoApproval": { "label": "Automatic approvals", "title": "Automatic approvals", - "description": "Approve every protected change in this conversation without another click. Your Homarr permissions still apply. This turns off when you switch conversations.", + "description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.", "enable": "Enable for this conversation", "disable": "Turn off automatic approvals", "enabled": "Automatic approvals are on",