diff --git a/frontend/src/agents/ApiWriteAllowlist.test.tsx b/frontend/src/agents/ApiWriteAllowlist.test.tsx
index b7458e23..3a3c78c5 100644
--- a/frontend/src/agents/ApiWriteAllowlist.test.tsx
+++ b/frontend/src/agents/ApiWriteAllowlist.test.tsx
@@ -81,12 +81,20 @@ describe('ApiWriteAllowlist', () => {
container.remove();
});
- const render = async (a: Agent, onConfigChange = vi.fn()) => {
+ const render = async (
+ a: Agent,
+ onConfigChange = vi.fn(),
+ getSavedConfig?: () => Agent['config'],
+ ) => {
store = makeStore();
await act(async () => {
root.render(
-
+
,
);
});
@@ -125,6 +133,27 @@ describe('ApiWriteAllowlist', () => {
});
});
+ it('saves on top of the last saved config, not unsaved form edits', async () => {
+ updateAgent.mockResolvedValue({ ok: true });
+ const saved = { guardrails: { controls: [] } };
+ const draft = agent({
+ config: { guardrails: { controls: [{ id: 'unsaved' }] } },
+ } as unknown as Partial);
+ const onConfigChange = await render(draft, vi.fn(), () => saved);
+ await act(async () =>
+ container
+ .querySelector('button[role="checkbox"]')!
+ .click(),
+ );
+ const expected = {
+ guardrails: { controls: [] },
+ api_write_allowlist: ['tg:telegram_send_message'],
+ };
+ const form = updateAgent.mock.calls[0][1] as FormData;
+ expect(JSON.parse(form.get('config') as string)).toEqual(expected);
+ expect(onConfigChange).toHaveBeenCalledWith(expected);
+ });
+
it('puts the choice back and says so when saving fails', async () => {
updateAgent.mockResolvedValue({ ok: false });
await render(agent());
diff --git a/frontend/src/agents/ApiWriteAllowlist.tsx b/frontend/src/agents/ApiWriteAllowlist.tsx
index d08d0ff0..a467f83f 100644
--- a/frontend/src/agents/ApiWriteAllowlist.tsx
+++ b/frontend/src/agents/ApiWriteAllowlist.tsx
@@ -36,13 +36,19 @@ type UserTool = {
* The write actions on the owner's connected accounts that anyone calling
* this agent with its API key may run. Nobody can approve an action in the
* widget or the API, so the server refuses every other such write.
+ *
+ * A toggle saves at once, on top of the agent's last saved config
+ * (`getSavedConfig`), so edits still pending in the form are not saved with
+ * it. `onConfigChange` receives the config as saved.
*/
export default function ApiWriteAllowlist({
agent,
onConfigChange,
+ getSavedConfig,
}: {
agent: Agent;
onConfigChange?: (config: AgentConfig) => void;
+ getSavedConfig?: () => AgentConfig | undefined;
}) {
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -101,7 +107,7 @@ export default function ApiWriteAllowlist({
: allowed.filter((item) => item !== entry);
setAllowed(next);
const config: AgentConfig = {
- ...(agent.config ?? {}),
+ ...((getSavedConfig ? getSavedConfig() : agent.config) ?? {}),
api_write_allowlist: next,
};
const form = new FormData();
diff --git a/frontend/src/agents/NewAgent.test.tsx b/frontend/src/agents/NewAgent.test.tsx
index 2c7aec76..74301b4c 100644
--- a/frontend/src/agents/NewAgent.test.tsx
+++ b/frontend/src/agents/NewAgent.test.tsx
@@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => {
catalog: [] as unknown[],
deleteAgent: vi.fn(() => jsonResponse({})),
guardrailsProps: vi.fn(),
+ detailsProps: vi.fn(),
};
});
const { jsonResponse } = mocks;
@@ -126,7 +127,12 @@ vi.mock('./components/GuardrailsSection', () => ({
guardrailsIncomplete: () => false,
}));
vi.mock('../upload/Upload', () => ({ default: () => null }));
-vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
+vi.mock('../modals/AgentDetailsModal', () => ({
+ default: (props: unknown) => {
+ mocks.detailsProps(props);
+ return null;
+ },
+}));
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
vi.mock('../modals/ConfirmationModal', () => ({
default: ({
@@ -793,6 +799,22 @@ describe('NewAgent gating by role', () => {
expect(token.disabled).toBe(true);
});
+ it('keeps the form clean after the API write allowlist saves', async () => {
+ await renderEdit('owner', OWNER);
+ const details = () =>
+ mocks.detailsProps.mock.calls.at(-1)![0] as {
+ onConfigChange: (config: Record) => void;
+ getSavedConfig: () => Record | undefined;
+ };
+ const saved = {
+ ...details().getSavedConfig(),
+ api_write_allowlist: ['tool-1:send'],
+ };
+ await act(async () => details().onConfigChange(saved));
+ expect(details().getSavedConfig()).toEqual(saved);
+ expect(buttonByText('agents.form.buttons.cancel')).toBeUndefined();
+ });
+
it('hides Access details without manage_access_details', async () => {
await renderEdit(
'editor',
diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx
index c2e7977d..cfa89e10 100644
--- a/frontend/src/agents/NewAgent.tsx
+++ b/frontend/src/agents/NewAgent.tsx
@@ -1656,7 +1656,20 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
modalState={agentDetails}
setModalState={setAgentDetails}
onKeyRegenerated={(key) => setAgent((prev) => ({ ...prev, key }))}
- onConfigChange={(config) => setAgent((prev) => ({ ...prev, config }))}
+ onConfigChange={(config) => {
+ // The allowlist is saved already: record it on the saved snapshot
+ // too, and keep any unsaved form edits to the rest of the config.
+ if (initialAgentRef.current)
+ initialAgentRef.current = { ...initialAgentRef.current, config };
+ setAgent((prev) => ({
+ ...prev,
+ config: {
+ ...(prev.config ?? {}),
+ api_write_allowlist: config.api_write_allowlist,
+ },
+ }));
+ }}
+ getSavedConfig={() => initialAgentRef.current?.config ?? agent.config}
/>
{shareModalOpen && agent.id && (
void;
onKeyRegenerated?: (key: string) => void;
- /** The agent's config after its API-key write allowlist changed. */
+ /** The agent's saved config after its API-key write allowlist changed. */
onConfigChange?: (config: AgentConfig) => void;
+ /** The agent's last saved config, which an allowlist change saves onto. */
+ getSavedConfig?: () => AgentConfig | undefined;
};
export default function AgentDetailsModal({
@@ -48,6 +50,7 @@ export default function AgentDetailsModal({
setModalState,
onKeyRegenerated,
onConfigChange,
+ getSavedConfig,
}: AgentDetailsModalProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -278,6 +281,7 @@ export default function AgentDetailsModal({
)}