fix assistant automatic approvals
This commit is contained in:
1 parent
8f81de775e
commit
9e0a6a758f
16 files changed
+403
-78
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<AssistantAskUserOption, "id" | "label">): 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<AssistantAskUserOption, "id" | "label"> & { 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",
|
||||
};
|
||||
};
|
||||
@@ -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<typeof useAssistantAutoApproval>;
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -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<AssistantAutoApprovalContextValue | null>(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 <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Group, Loader, Text } from "@mantine/core";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
|
||||
export const AssistantAutomaticActionProgress = ({ label }: { label: string }) => (
|
||||
<Group component="output" className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
|
||||
<Loader type="bars" size="sm" color="green" />
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -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<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
|
||||
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 <BoardSettingsSkeleton label={t("loading")} />;
|
||||
|
||||
if (autoConfirming) {
|
||||
return (
|
||||
<Box className={classes.boardSettingsTool}>
|
||||
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BoardSettingsForm
|
||||
current={settings.data}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getAssistantBoardSettingsResult,
|
||||
getAssistantBoardSettingsDefaultTab,
|
||||
getChangedBoardSettings,
|
||||
getCustomCssWarnings,
|
||||
@@ -21,6 +22,20 @@ describe("getChangedBoardSettings", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<AskUserArgs, AskUserResult>) => {
|
||||
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<HTMLInputElement>(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 = ({
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
{options.map((option, index) => (
|
||||
<Button
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
variant="default"
|
||||
size="md"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
disabled={submitting}
|
||||
onClick={() => submitResult({ answer: option.label, optionId: option.id, source: "option" })}
|
||||
>
|
||||
<Box ta="start">
|
||||
<Text component="span" size="sm" fw={650}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Button>
|
||||
))}
|
||||
{autoConfirming ? (
|
||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
{args.allowOther !== false &&
|
||||
return (
|
||||
<Button
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
variant={kind === "alternative" ? "default" : "light"}
|
||||
color={color}
|
||||
size="md"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
leftSection={icon}
|
||||
rightSection={
|
||||
<Badge variant="light" color={color} size="xs">
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
<Box ta="start">
|
||||
<Text component="span" size="sm" fw={650}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{!autoConfirming &&
|
||||
args.allowOther !== false &&
|
||||
(showOther ? (
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
@@ -185,6 +235,11 @@ export const AssistantAskUserTool = ({
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<IconPencil size={17} />}
|
||||
rightSection={
|
||||
<Badge variant="light" color="gray" size="xs">
|
||||
{t("optionKind.custom")}
|
||||
</Badge>
|
||||
}
|
||||
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 (
|
||||
<Box className={classes.appTool}>
|
||||
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
<Stack gap={2} mb="md">
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Box className={classes.tool}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
Reference in new issue
Block a user