Ver Fonte

Ai chat form buttons (#5927)

* AI chat: "form" buttons open a site form in a popup over the chat

Agents can show a button which opens a site form (any API service form,
`module/method/&params[]=..`) over the chat, e.g. to connect a cloud
account without pasting keys into the chat. After the form is saved the
button `message` is sent as the visitor's answer, so the agent goes on.

- BxDolAiChatUi::sanitizeChatActions keeps `form` buttons with a vetted
  form_url and an optional message
- chat_buttons tool describes the new button type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: AI chat "form" buttons open the site form in a popup

A `form` chat action loads `/api.php?r={form_url}` into a modal over the
chat, like the deploy block does. When the server answers the submit with
"done" (onFormEmpty) the modal closes and the action `message` is sent as
the user's answer; closing it with X sends nothing. form_url is checked
against the same `module/method/&params[]=` pattern the server uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AI chat: link buttons the chat waits for (check_url + message)

A link chat action may carry `check_url` (an API service call,
`module/method/&params[]=..`) and `message`: after the link is opened the
chat polls check_url and sends the message as the visitor's answer once it
reports done. E.g. "Add using OAuth": the agent goes on as soon as the OAuth
callback connected the account, without a "Done" button.

The service URL check and the message cleanup are shared with form buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: AI chat waits for a link with check_url and answers by itself

A link action with `check_url` and `message` starts polling
`/api.php?r={check_url}` every 3 s when pressed (up to 10 min); once the
service answers `done` the message is sent as the user's answer and the
button reads as chosen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: AI chat highlights only the pressed button when several send the same answer

"Add using OAuth" and "Add keys manually" both answer "… is connected":
only the one that was pressed reads as chosen (after a reload the first one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* move to tsx

* AI chat buttons: fix review comments

- chat-actions.tsx: useTranslation, error text in Text with theme token, readonly props
- chat_buttons tool: describe check_url and link message
- sanitizeChatActionMessage uses get_mb_substr
- unit tests for sanitizeChatActions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AI chat buttons: test merging form actions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL há 1 dia atrás
pai
commit
e643157ce9

+ 3 - 2
inc/classes/BxDolAIToolChatButtons.php

@@ -33,7 +33,7 @@ class BxDolAIToolChatButtons extends BxDolAITool
         return [
             new ArrayProperty(
                 name: 'buttons',
-                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration); form = opens a site form in a popup over the chat, form_url comes from a tool result only (never made up), after the form is saved the "message" text is sent as the visitor next message. Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
+                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration), with check_url and message from a tool result the chat waits for it and sends message by itself; form = opens a site form in a popup over the chat, form_url comes from a tool result only (never made up), after the form is saved the "message" text is sent as the visitor next message. Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
                 required: true,
                 items: new ObjectProperty(
                     name: 'button',
@@ -42,7 +42,8 @@ class BxDolAIToolChatButtons extends BxDolAITool
                         new ToolProperty('label', PropertyType::STRING, 'Button text shown to the visitor', true),
                         new ToolProperty('url', PropertyType::STRING, 'Required for link. https URL.', false),
                         new ToolProperty('form_url', PropertyType::STRING, 'Required for form. Exactly the form_url from a tool result, like "module/method/&params[]=1".', false),
-                        new ToolProperty('message', PropertyType::STRING, 'For form. Sent as the visitor message after the form is saved, in the site language, e.g. "DigitalOcean is connected".', false),
+                        new ToolProperty('check_url', PropertyType::STRING, 'Optional for link, together with message. Exactly the check_url from a tool result, like "module/method/&params[]=1": the chat polls it after the link is opened and sends message once it is done.', false),
+                        new ToolProperty('message', PropertyType::STRING, 'For form, and for link with check_url. Sent as the visitor message after the form is saved or the check_url reports done, in the site language, e.g. "DigitalOcean is connected".', false),
                     ]
                 ),
                 minItems: 1,

+ 30 - 5
inc/classes/BxDolAiChatUi.php

@@ -337,16 +337,24 @@ class BxDolAiChatUi
                 $sUrl = trim((string)($aItem['url'] ?? ''));
                 if (!$this->isAllowedChatActionUrl($sUrl))
                     continue;
-                $aOut[] = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+                $aLink = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+                // a link the chat waits for (e.g. OAuth): it polls check_url and sends message when done
+                $sCheckUrl = trim((string)($aItem['check_url'] ?? ''));
+                $sMessage = $this->sanitizeChatActionMessage($aItem['message'] ?? '');
+                if ($sMessage !== '' && $this->isAllowedChatActionServiceUrl($sCheckUrl)) {
+                    $aLink['check_url'] = $sCheckUrl;
+                    $aLink['message'] = $sMessage;
+                }
+                $aOut[] = $aLink;
             } elseif ($sType === 'form') {
-                // form opened in a popup over the chat: 'module/method/&params[]=..' API service call
+                // form opened in a popup over the chat
                 $sFormUrl = trim((string)($aItem['form_url'] ?? ''));
-                if (!preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', $sFormUrl))
+                if (!$this->isAllowedChatActionServiceUrl($sFormUrl))
                     continue;
                 $aForm = ['type' => 'form', 'label' => $sLabel, 'form_url' => $sFormUrl];
-                $sMessage = trim(strip_tags((string)($aItem['message'] ?? '')));
+                $sMessage = $this->sanitizeChatActionMessage($aItem['message'] ?? '');
                 if ($sMessage !== '')
-                    $aForm['message'] = function_exists('mb_substr') ? mb_substr($sMessage, 0, 200) : substr($sMessage, 0, 200);
+                    $aForm['message'] = $sMessage;
                 $aOut[] = $aForm;
             }
 
@@ -512,6 +520,23 @@ class BxDolAiChatUi
         return strtolower((string)$mixed);
     }
 
+    /**
+     * API service call of a chat action: 'module/method/&params[]=..', nothing else.
+     */
+    protected function isAllowedChatActionServiceUrl($sUrl)
+    {
+        return (bool)preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', (string)$sUrl);
+    }
+
+    /**
+     * Text a chat action sends as the visitor answer: plain, one line, max 200 chars.
+     */
+    protected function sanitizeChatActionMessage($mixed)
+    {
+        $s = trim(strip_tags((string)$mixed));
+        return get_mb_substr($s, 0, 200);
+    }
+
     protected function isAllowedChatActionUrl($sUrl)
     {
         $a = parse_url((string)$sUrl);

+ 0 - 128
neo/packages/app/ui/molecules/ai-agent/chat-actions.js

@@ -1,128 +0,0 @@
-'use client';
-
-import { memo, useCallback, useEffect, useState } from 'react';
-import { View } from 'app/design/view';
-import { NeoButton, NeoButtonLink, Modal } from 'app/design/controls';
-import { fetcher } from 'app/lib/fetcher';
-
-/**
- * The quick-reply / link / form buttons an assistant message can carry.
- *
- * Selection has one authority: `chosenReply`, derived by the parent from the user
- * message that actually followed this bubble. `pending` is only an optimistic bridge
- * covering the round-trip, and it is cleared as soon as the transcript confirms the
- * choice or the send reports failure — so a failed send can no longer leave a button
- * highlighted as if it had been answered.
- *
- * A form button opens the site form (`/api.php?r={formUrl}`) in a popup over the chat,
- * like the deploy block does; once the form is saved the popup closes and the action
- * `message` is sent as the user's answer, so the agent goes on by itself.
- */
-function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' }) {
-    const [pending, setPending] = useState('');
-    const [form, setForm] = useState(null);
-    const [opening, setOpening] = useState(false);
-    const selected = chosenReply || pending;
-
-    useEffect(() => {
-        // The transcript now records the answer; the optimistic copy has no job left.
-        if (chosenReply) setPending('');
-    }, [chosenReply]);
-
-    const press = useCallback(async (message) => {
-        setPending(message);
-        // onReply resolves false when the message never made it into the transcript.
-        const sent = await onReply?.(message);
-        if (!sent) setPending('');
-    }, [onReply]);
-
-    const openForm = useCallback(async (action) => {
-        setOpening(true);
-        try {
-            // Loaded on demand: the block renderer imports the component registry,
-            // which in turn imports this chat.
-            const [{ BlockByDataInt }, response] = await Promise.all([
-                import('app/components/block'),
-                fetcher(`/api.php?r=${action.formUrl}`),
-            ]);
-            setForm({
-                Block: BlockByDataInt,
-                block: { content: response?.data, designbox_id: 0, title: action.label },
-                message: action.message,
-            });
-        } finally {
-            setOpening(false);
-        }
-    }, []);
-
-    const closeForm = useCallback(() => setForm(null), []);
-
-    // The form calls onFormEmpty once the server answered the submit with "done";
-    // closing the popup with its X does not, so only a saved form sends the answer.
-    const completeForm = useCallback(() => {
-        const message = form?.message;
-        setForm(null);
-        if (message && repliesEnabled) void press(message);
-    }, [form, press, repliesEnabled]);
-
-    if (!actions.length) return null;
-
-    return (
-        <View className="flex-row flex-wrap gap-2 mt-2 max-w-full">
-            {actions.map((action, index) => {
-                const isLink = action.type === 'link';
-                const isForm = action.type === 'form';
-                const isSelected = !isLink && !!action.message && action.message === selected;
-                // Index is part of the key because an agent may legitimately repeat a
-                // label within one message ("Other", "Other").
-                const key = `${action.type}:${action.label}:${index}`;
-                const buttonProps = {
-                    label: action.label,
-                    style: isSelected ? 'glassProminent' : 'bordered',
-                    controlSize: 'small',
-                    borderShape: 'rounded',
-                };
-                if (isLink) {
-                    // Target vetting happens in messageActions(); NeoButtonLink routes
-                    // internal hrefs through the SPA router and external ones to the
-                    // in-app browser.
-                    return <NeoButtonLink key={key} {...buttonProps} href={action.url} target="_blank" />;
-                }
-                if (isForm) {
-                    // Like a link, a form stays usable after the turn is over; only the
-                    // answer it sends on save needs the turn to be open.
-                    return (
-                        <NeoButton
-                            key={key}
-                            {...buttonProps}
-                            selected={isSelected}
-                            disabled={opening}
-                            onPress={() => void openForm(action)}
-                        />
-                    );
-                }
-                return (
-                    <NeoButton
-                        key={key}
-                        {...buttonProps}
-                        selected={isSelected}
-                        // The answered option stays enabled so it keeps reading as the
-                        // choice that was made; the alternatives lock once the turn is over.
-                        disabled={!isSelected && !repliesEnabled}
-                        onPress={() => {
-                            if (!repliesEnabled || isSelected) return;
-                            void press(action.message);
-                        }}
-                    />
-                );
-            })}
-            {form && (
-                <Modal scrollable={true} title={form.block.title} onVisible={true} onClose={closeForm}>
-                    <form.Block block={form.block} onFormEmpty={completeForm} />
-                </Modal>
-            )}
-        </View>
-    );
-}
-
-export const ChatActions = memo(ChatActionsImpl);

+ 216 - 0
neo/packages/app/ui/molecules/ai-agent/chat-actions.tsx

@@ -0,0 +1,216 @@
+'use client';
+
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { View } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { NeoButton, NeoButtonLink, Modal } from 'app/design/controls';
+import { fetcher } from 'app/lib/fetcher';
+import { useTranslation } from 'react-i18next';
+
+// A link the chat waits for (e.g. OAuth in another tab): poll every 3 s, give up after 10 min.
+const WATCH_INTERVAL = 3000;
+const WATCH_TIMEOUT = 10 * 60 * 1000;
+
+export type ChatAction = {
+    type: 'reply' | 'link' | 'form';
+    label: string;
+    message?: string;
+    url?: string;
+    checkUrl?: string;
+    formUrl?: string;
+};
+
+type ChatActionsProps = {
+    actions: ChatAction[];
+    onReply?: (message: string) => Promise<boolean> | boolean;
+    repliesEnabled: boolean;
+    chosenReply?: string;
+};
+
+type OpenForm = {
+    Block: any;
+    block: { content: any; designbox_id: number; title: string };
+    message?: string;
+};
+
+type Watch = { checkUrl: string; message?: string; until: number };
+
+/**
+ * The quick-reply / link / form buttons an assistant message can carry.
+ *
+ * Selection has one authority: `chosenReply`, derived by the parent from the user
+ * message that actually followed this bubble. `pending` is only an optimistic bridge
+ * covering the round-trip, cleared once the transcript confirms the choice or the
+ * send reports failure.
+ *
+ * A form button opens the site form (`/api.php?r={formUrl}`) in a popup over the chat;
+ * once the form is saved the popup closes and the action `message` is sent.
+ *
+ * A link with `checkUrl` opens as usual, then the chat polls `/api.php?r={checkUrl}`
+ * and sends the link `message` itself once it answers `done`.
+ */
+function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' }: Readonly<ChatActionsProps>) {
+    const { t } = useTranslation();
+    const [pending, setPending] = useState('');
+    const [form, setForm] = useState<OpenForm | null>(null);
+    const [opening, setOpening] = useState(false);
+    const [formError, setFormError] = useState('');
+    // Several buttons may send the same answer: only the pressed one reads as chosen.
+    const [pressedIndex, setPressedIndex] = useState<number | null>(null);
+    const selected = chosenReply || pending;
+
+    useEffect(() => {
+        if (chosenReply) setPending('');
+    }, [chosenReply]);
+
+    const press = useCallback(async (message: string) => {
+        setPending(message);
+        const sent = await onReply?.(message);
+        if (!sent) setPending('');
+    }, [onReply]);
+
+    const openForm = useCallback(async (action: ChatAction) => {
+        setOpening(true);
+        setFormError('');
+        try {
+            // Loaded on demand: the block renderer imports the component registry,
+            // which in turn imports this chat.
+            const [{ BlockByDataInt }, response] = await Promise.all([
+                import('app/components/block'),
+                fetcher(`/api.php?r=${action.formUrl}`),
+            ]);
+            if (!response?.data) throw new Error('empty form');
+            setForm({
+                Block: BlockByDataInt,
+                block: { content: response.data, designbox_id: 0, title: action.label },
+                message: action.message,
+            });
+        } catch {
+            // The button stays enabled, so pressing it again retries.
+            setFormError(t('Something went wrong'));
+        } finally {
+            setOpening(false);
+        }
+    }, [t]);
+
+    const closeForm = useCallback(() => setForm(null), []);
+
+    // Latest press / repliesEnabled for the polling loop, which outlives renders.
+    const pressRef = useRef(press);
+    const repliesEnabledRef = useRef(repliesEnabled);
+    useEffect(() => {
+        pressRef.current = press;
+        repliesEnabledRef.current = repliesEnabled;
+    }, [press, repliesEnabled]);
+
+    const [watch, setWatch] = useState<Watch | null>(null);
+
+    useEffect(() => {
+        if (!watch) return undefined;
+
+        // Chained timeouts instead of an interval: the next check starts only after
+        // the previous one answered, so slow responses never overlap.
+        let stopped = false;
+        let timer: ReturnType<typeof setTimeout> | undefined;
+        const check = async () => {
+            if (stopped) return;
+            if (Date.now() > watch.until) {
+                setWatch(null);
+                return;
+            }
+            try {
+                const response = await fetcher(`/api.php?r=${watch.checkUrl}`);
+                if (stopped) return;
+                if (response?.data?.done) {
+                    stopped = true;
+                    setWatch(null);
+                    if (watch.message && repliesEnabledRef.current) void pressRef.current(watch.message);
+                    return;
+                }
+            } catch {
+                // Network hiccup: keep waiting until the timeout.
+            }
+            if (!stopped) timer = setTimeout(check, WATCH_INTERVAL);
+        };
+        timer = setTimeout(check, WATCH_INTERVAL);
+
+        return () => {
+            stopped = true;
+            if (timer) clearTimeout(timer);
+        };
+    }, [watch]);
+
+    // The form calls onFormEmpty once the server answered the submit with "done";
+    // closing the popup with its X does not, so only a saved form sends the answer.
+    const completeForm = useCallback(() => {
+        const message = form?.message;
+        setForm(null);
+        if (message && repliesEnabled) void press(message);
+    }, [form, press, repliesEnabled]);
+
+    if (!actions.length) return null;
+
+    const firstSelectedIndex = selected ? actions.findIndex((action) => action.message === selected) : -1;
+
+    return (
+        <View className="flex-row flex-wrap gap-2 mt-2 max-w-full">
+            {actions.map((action, index) => {
+                const isSelected = !!action.message && action.message === selected
+                    && (pressedIndex !== null && actions[pressedIndex]?.message === selected ? pressedIndex === index : firstSelectedIndex === index);
+                // Index is part of the key: an agent may repeat a label ("Other", "Other").
+                const key = `${action.type}:${action.label}:${index}`;
+                const buttonProps = {
+                    label: action.label,
+                    style: isSelected ? 'glassProminent' : 'bordered',
+                    controlSize: 'small',
+                    borderShape: 'rounded',
+                };
+                if (action.type === 'link') {
+                    const checkUrl = action.checkUrl;
+                    const startWatch = checkUrl
+                        ? () => {
+                            setPressedIndex(index);
+                            setWatch({ checkUrl, message: action.message, until: Date.now() + WATCH_TIMEOUT });
+                        }
+                        : undefined;
+                    return <NeoButtonLink key={key} {...buttonProps} href={action.url} target="_blank" onPress={startWatch} />;
+                }
+                if (action.type === 'form') {
+                    return (
+                        <NeoButton
+                            key={key}
+                            {...buttonProps}
+                            selected={isSelected}
+                            disabled={opening}
+                            onPress={() => {
+                                setPressedIndex(index);
+                                void openForm(action);
+                            }}
+                        />
+                    );
+                }
+                return (
+                    <NeoButton
+                        key={key}
+                        {...buttonProps}
+                        selected={isSelected}
+                        disabled={!isSelected && !repliesEnabled}
+                        onPress={() => {
+                            if (!repliesEnabled || isSelected || !action.message) return;
+                            setPressedIndex(index);
+                            void press(action.message);
+                        }}
+                    />
+                );
+            })}
+            {!!formError && <Text className="w-full text-destructive text-sm">{formError}</Text>}
+            {form && (
+                <Modal scrollable={true} title={form.block.title} onVisible={true} onClose={closeForm}>
+                    <form.Block block={form.block} onFormEmpty={completeForm} />
+                </Modal>
+            )}
+        </View>
+    );
+}
+
+export const ChatActions = memo(ChatActionsImpl);

+ 12 - 2
neo/packages/app/ui/molecules/ai-agent/message-content.js

@@ -231,7 +231,8 @@ function safeActionUrl(raw) {
 }
 
 /**
- * Normalize a message's actions into `{ type, label, url }` / `{ type, label, message }` /
+ * Normalize a message's actions into `{ type, label, url }` (plus `checkUrl`, `message`
+ * for a link the chat waits for) / `{ type, label, message }` /
  * `{ type: 'form', label, formUrl, message }`.
  * `streamedActions` (from the live CUSTOM chunk) wins over whatever is on the
  * message, because the message copy may not have been persisted yet.
@@ -259,7 +260,16 @@ export function messageActions(message, streamedActions) {
             // but whose target we rejected must be dropped, never silently downgraded
             // to a reply that would push the label into the chat as if the user typed it.
             const isLink = action.type === 'link' || (!action.type && !!rawUrl);
-            if (isLink) return url ? { type: 'link', label, url } : null;
+            if (isLink) {
+                if (!url) return null;
+                // Optional "wait for it" link (e.g. OAuth): after the press the chat polls
+                // `check_url` and sends `message` itself once the server reports it done.
+                const checkUrl = String(action.check_url || '').trim();
+                const linkMessage = String(action.message || '').trim();
+                return SAFE_FORM_URL.test(checkUrl) && linkMessage
+                    ? { type: 'link', label, url, checkUrl, message: linkMessage }
+                    : { type: 'link', label, url };
+            }
             return { type: 'reply', label, message: String(action.message || label).trim() || label };
         })
         .filter(Boolean);

+ 99 - 0
tests/units/inc/classes/BxDolAiChatUiTest.php

@@ -0,0 +1,99 @@
+<?php
+
+/**
+ * Chat action buttons the agent asks for: only safe types, URLs and texts reach the chat.
+ */
+class BxDolAiChatUiTest extends \PHPUnit\Framework\TestCase
+{
+    private function sanitize($aActions)
+    {
+        return BxDolAiChatUi::getInstance()->sanitizeChatActions($aActions);
+    }
+
+    public function testRejectsBadInput()
+    {
+        $this->assertSame([], $this->sanitize('reply'));
+        $this->assertSame([], $this->sanitize([
+            'not an array',
+            ['type' => 'reply', 'label' => '  '],
+            ['type' => 'script', 'label' => 'X'],
+        ]));
+    }
+
+    public function testReplyAndLink()
+    {
+        $this->assertSame([
+            ['type' => 'reply', 'label' => 'Yes'],
+            ['type' => 'link', 'label' => 'Help', 'url' => 'https://una.io/help'],
+        ], $this->sanitize([
+            ['type' => 'REPLY', 'label' => ' Yes '],
+            ['type' => 'link', 'label' => 'Help', 'url' => 'https://una.io/help'],
+            ['type' => 'link', 'label' => 'Http', 'url' => 'http://una.io/'],
+            ['type' => 'link', 'label' => 'Js', 'url' => 'javascript:alert(1)'],
+            ['type' => 'link', 'label' => 'Local', 'url' => 'https://localhost/'],
+            ['type' => 'link', 'label' => 'Private', 'url' => 'https://10.0.0.5/'],
+        ]));
+    }
+
+    public function testLinkCheckUrlNeedsMessageAndServiceUrl()
+    {
+        $aOut = $this->sanitize([
+            ['type' => 'link', 'label' => 'A', 'url' => 'https://a.test/', 'check_url' => 'bx_projects/get_provider_connected/&params[]=aws&params[]=1', 'message' => 'AWS is connected'],
+            ['type' => 'link', 'label' => 'B', 'url' => 'https://a.test/', 'check_url' => 'bx_projects/get_provider_connected'],
+            ['type' => 'link', 'label' => 'C', 'url' => 'https://a.test/', 'check_url' => 'https://evil.test/x', 'message' => 'm'],
+            ['type' => 'link', 'label' => 'D', 'url' => 'https://a.test/', 'check_url' => 'a/b/&params[]=<x>', 'message' => 'm'],
+        ]);
+
+        $this->assertSame('bx_projects/get_provider_connected/&params[]=aws&params[]=1', $aOut[0]['check_url']);
+        $this->assertSame('AWS is connected', $aOut[0]['message']);
+        foreach ([1, 2, 3] as $i) {
+            $this->assertArrayNotHasKey('check_url', $aOut[$i]);
+            $this->assertArrayNotHasKey('message', $aOut[$i]);
+        }
+    }
+
+    public function testForm()
+    {
+        $this->assertSame([
+            ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=2', 'message' => 'AWS is connected'],
+            ['type' => 'form', 'label' => 'No message', 'form_url' => 'bx_projects/get_block_sr_provider_add'],
+        ], $this->sanitize([
+            ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=2', 'message' => '<b>AWS</b> is connected'],
+            ['type' => 'form', 'label' => 'No message', 'form_url' => 'bx_projects/get_block_sr_provider_add', 'message' => ' '],
+            ['type' => 'form', 'label' => 'Bad', 'form_url' => '../../admin'],
+            ['type' => 'form', 'label' => 'Bad', 'form_url' => 'https://evil.test/a/b'],
+            ['type' => 'form', 'label' => 'Missing'],
+        ]));
+    }
+
+    public function testMergeFormActions()
+    {
+        $oChat = BxDolAiChatUi::getInstance();
+        $aKeys = ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=2'];
+        $aRole = ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=3'];
+        $aBad = ['type' => 'form', 'label' => 'Bad', 'form_url' => 'https://evil.test/a/b'];
+
+        // same label and form_url is one button, another form_url is another one
+        $this->assertSame([$aKeys, $aRole], $oChat->mergeChatActions([$aKeys, $aBad], [$aKeys, $aRole]));
+
+        // a form never merges with a link of the same label
+        $aLink = ['type' => 'link', 'label' => 'Add keys', 'url' => 'https://a.test/'];
+        $this->assertCount(2, $oChat->mergeChatActions([$aKeys], [$aLink]));
+
+        $this->assertCount(8, $oChat->mergeChatActions(
+            array_map(fn($i) => ['type' => 'form', 'label' => 'F' . $i, 'form_url' => 'a/b'], range(1, 6)),
+            array_map(fn($i) => ['type' => 'reply', 'label' => 'R' . $i], range(1, 6))
+        ));
+    }
+
+    public function testLimits()
+    {
+        $aOut = $this->sanitize([
+            ['type' => 'form', 'label' => str_repeat('я', 100), 'form_url' => 'a/b', 'message' => str_repeat('я', 300)],
+        ]);
+        $this->assertSame(80, mb_strlen($aOut[0]['label']));
+        $this->assertSame(200, mb_strlen($aOut[0]['message']));
+
+        $this->assertCount(8, $this->sanitize(array_fill(0, 12, ['type' => 'reply', 'label' => 'x'])));
+    }
+}