fix assistant automatic approvals

This commit is contained in:
ajnart committed 2026-08-03 12:34:54 +02:00
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"]>;