mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +00:00
🔔 fix: Report Agent Saves That Reuse the Newest Version Entry (#14824)
* fix: report agent saves that reuse the newest version entry An update whose result matches the newest version is written without recording a version entry. The Agent Builder derived its success message from the version count, so every such save reported "No changes were made" while the edit had in fact been persisted. Base the message on whether the submission carried an edit of its own instead, and keep the version count for the version history panel. Also stop suppressing the version entry when the update carries an atomic operator. isDuplicateVersion compares direct updates only, so it cannot speak for the operator half; suppressing there applied a change that no version entry recorded, leaving the document diverged from every entry in its own history. Closes #14809 * fix: count an avatar reset as a persisted edit An avatar upload uses its own endpoint, but a reset rides the update payload as avatar: null, so classifying every avatar-only submission as non-persisted was wrong for resets. Clearing an avatar the newest version never recorded reads as a duplicate to isDuplicateVersion, since it skips a field when both sides are falsy, so the reset landed with the version count unchanged and reported "No changes were made". * fix: skip the version entry when an atomic operator changes nothing An update carrying $push, $pull or $addToSet bypassed duplicate suppression on the operator's mere presence. Re-attaching a resource file an agent already holds makes $addToSet a no-op, so an agent with actions recorded a version entry for a write that never touched the document, and its version count climbed on retries. Resolve the operators against the current document instead. $push always appends and $pull matches arbitrary criteria, so both still count as mutating; $addToSet counts only when some value it adds is missing. Whatever cannot be compared cheaply counts as mutating, since over-reporting costs a redundant version while under-reporting would apply a change no version records. * fix: confirm the submitted edit survived before claiming a save changed anything Treating a dirty form as proof of a persisted edit reports success for a save that stored nothing. The server can normalize a submission straight back to the stored value: an MCP tool the user added is dropped when authorization rejects it, and a skill is pruned when it no longer exists. Neither moves the version count, so the toast claimed the agent was updated when it was untouched. Capture the agent as it stands before the write, since the mutation replaces that cache entry on success, and compare it against the one the server returns across the fields the submission carried. Keep the dirty check alongside it: an agent loaded through the basic projection carries fewer fields than the update endpoint returns, and pairing the two keeps an untouched save honest either way. * fix: compare a save against the expanded agent, not a basic projection The panel falls back to the basic agent query whenever the expanded one has not resolved, and that projection drops instructions, tools, edges, skills and the rest while reducing model_parameters to a single flag. Comparing a submission against it made every one of those fields read as changed, so a rejected MCP tool or a pruned skill still reported success. Compare against the expanded agent, the only projection carrying every field a submission sends. When it is unavailable the comparison reports true and leaves the dirty check to decide, since claiming nothing changed for a save that did is the worse of the two errors. Renamed to say what it now answers. * fix: drop the operator a suppressed update judged a no-op Suppression reads whether $addToSet would add anything from a document fetched before the write, and that reading cannot bind a concurrent one. A $pull landing in between leaves the operator re-adding the value while the version entry has already been suppressed, which is the one outcome this path exists to prevent: a change applied with nothing in the history recording it. Drop what was judged a no-op instead of racing it. Only $addToSet reaches here, and only once every value it adds was found stored, so removing it makes the suppression true by construction rather than true if nothing else writes first. * fix: leave a suppressed update carrying no operator at all Dropping only $addToSet left the invariant resting on which operators callers happen to send. A present but empty $push or $pull counts as no operator when deciding suppression, yet survived into the write, so the suppressed update was operator-free by convention rather than by construction. Drop all three. Reaching suppression already means none of them can change the document, so removing them states that outright and keeps the write consistent with the history it declines to record.
This commit is contained in:
parent
69ce4b7b00
commit
336703fe48
5 changed files with 665 additions and 38 deletions
|
|
@ -2,9 +2,11 @@
|
|||
* @jest-environment jsdom
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
|
||||
// Mock toast context - define this after all mocks
|
||||
let mockShowToast: jest.Mock;
|
||||
|
|
@ -167,6 +169,7 @@ jest.mock('./AgentFooter', () => ({
|
|||
|
||||
// Mock react-hook-form to capture form submission
|
||||
let mockFormSubmitHandler: (() => void) | null = null;
|
||||
let capturedFormMethods: UseFormReturn<AgentForm> | null = null;
|
||||
|
||||
jest.mock('react-hook-form', () => {
|
||||
const actual = jest.requireActual('react-hook-form') as any;
|
||||
|
|
@ -187,6 +190,8 @@ jest.mock('react-hook-form', () => {
|
|||
},
|
||||
});
|
||||
|
||||
capturedFormMethods = methods;
|
||||
|
||||
return {
|
||||
...methods,
|
||||
handleSubmit: (onSubmit: any) => (e?: any) => {
|
||||
|
|
@ -203,7 +208,7 @@ jest.mock('react-hook-form', () => {
|
|||
|
||||
// Import after mocks
|
||||
import { dataService } from 'librechat-data-provider';
|
||||
import { useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useGetAgentByIdQuery, useGetExpandedAgentByIdQuery } from '~/data-provider';
|
||||
import AgentPanel from './AgentPanel';
|
||||
|
||||
// Mock useGetAgentByIdQuery
|
||||
|
|
@ -212,10 +217,7 @@ jest.mock('~/data-provider', () => {
|
|||
return {
|
||||
...actual,
|
||||
useGetAgentByIdQuery: jest.fn(),
|
||||
useGetExpandedAgentByIdQuery: jest.fn(() => ({
|
||||
data: null,
|
||||
isInitialLoading: false,
|
||||
})),
|
||||
useGetExpandedAgentByIdQuery: jest.fn(),
|
||||
useUpdateAgentMutation: actual.useUpdateAgentMutation,
|
||||
};
|
||||
});
|
||||
|
|
@ -250,14 +252,22 @@ const mockAgentQuery = (
|
|||
mockUseGetAgentByIdQuery: jest.MockedFunction<typeof useGetAgentByIdQuery>,
|
||||
agent: Partial<Agent>,
|
||||
) => {
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({
|
||||
data: {
|
||||
id: 'agent-123',
|
||||
author: 'user-123',
|
||||
...agent,
|
||||
} as Agent,
|
||||
isInitialLoading: false,
|
||||
} as any);
|
||||
const data = {
|
||||
id: 'agent-123',
|
||||
author: 'user-123',
|
||||
/** Matches `createMockAgent`, so a field the submission carries but never edits
|
||||
* compares equal across the update rather than reading as a change. */
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
...agent,
|
||||
} as Agent;
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data, isInitialLoading: false } as any);
|
||||
/** The panel resolves to the expanded query once it has data, and only that projection
|
||||
* carries every field the submission compares against. */
|
||||
(
|
||||
useGetExpandedAgentByIdQuery as jest.MockedFunction<typeof useGetExpandedAgentByIdQuery>
|
||||
).mockReturnValue({ data, isInitialLoading: false } as any);
|
||||
};
|
||||
|
||||
const createMockAgent = (overrides: Partial<Agent> = {}): Agent =>
|
||||
|
|
@ -289,6 +299,7 @@ describe('AgentPanel - Update Agent Toast Messages', () => {
|
|||
jest.clearAllMocks();
|
||||
mockShowToast = jest.fn();
|
||||
mockFormSubmitHandler = null;
|
||||
capturedFormMethods = null;
|
||||
});
|
||||
|
||||
describe('AgentPanel', () => {
|
||||
|
|
@ -315,6 +326,111 @@ describe('AgentPanel - Update Agent Toast Messages', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should show "update success" toast when an edited agent reuses the same version', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
version: 2,
|
||||
});
|
||||
|
||||
/** An update whose result matches the newest version is written without recording a
|
||||
* version entry, so the count comes back unchanged even though the edit was saved. */
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Renamed Agent', version: 2 }));
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
const { container } = render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
capturedFormMethods!.setValue('name', 'Renamed Agent', { shouldDirty: true });
|
||||
});
|
||||
|
||||
fireEvent.submit(container.querySelector('form')!);
|
||||
mockFormSubmitHandler?.();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_assistants_update_success_name',
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'com_ui_no_changes' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show "update success" toast when an avatar reset reuses the same version', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
version: 2,
|
||||
avatar: { filepath: '/images/agent-123/avatar.png', source: 'local' },
|
||||
});
|
||||
|
||||
/** A reset rides the update payload as `avatar: null`, and clearing an avatar the
|
||||
* newest version never recorded reads as a duplicate, so the count comes back
|
||||
* unchanged even though the avatar was deleted. */
|
||||
mockUpdateAgent.mockResolvedValue(
|
||||
createMockAgent({ name: 'Test Agent', version: 2, avatar: null }),
|
||||
);
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
const { container } = render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
capturedFormMethods!.setValue('avatar_action', 'reset', { shouldDirty: true });
|
||||
});
|
||||
|
||||
fireEvent.submit(container.querySelector('form')!);
|
||||
mockFormSubmitHandler?.();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_assistants_update_success_name',
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'com_ui_no_changes' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show "no changes" toast when the server drops the submitted edit', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
version: 2,
|
||||
tools: ['keep'],
|
||||
});
|
||||
|
||||
/** An MCP tool the user added can be stripped by authorization before the write, so
|
||||
* a dirty submission is no promise that anything was persisted. */
|
||||
mockUpdateAgent.mockResolvedValue(
|
||||
createMockAgent({ name: 'Test Agent', version: 2, tools: ['keep'] }),
|
||||
);
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
const { container } = render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
capturedFormMethods!.setValue('tools', ['keep', 'rejected_mcp_tool'], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.submit(container.querySelector('form')!);
|
||||
mockFormSubmitHandler?.();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_ui_no_changes',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "update success" toast when version changes', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useMemo, useCallback, useRef, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { useWatch, useForm, FormProvider } from 'react-hook-form';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
|
|
@ -12,8 +13,8 @@ import {
|
|||
PermissionBits,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Agent, AgentUpdateParams } from 'librechat-data-provider';
|
||||
import type { FieldNamesMarkedBoolean } from 'react-hook-form';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentForm, StringOption } from '~/common';
|
||||
import {
|
||||
|
|
@ -224,6 +225,52 @@ export const isAvatarUploadOnlyDirty = (
|
|||
return result.sawDirty && result.onlyAvatarDirty;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the submission carries an edit the agent update endpoint persists. Only an
|
||||
* avatar upload travels through its own endpoint; a reset rides the update payload as
|
||||
* `avatar: null` (see `composeAgentUpdatePayload`), so it is an edit like any other.
|
||||
*/
|
||||
export const hasPersistedDirtyFields = (
|
||||
dirtyFields?: FieldNamesMarkedBoolean<AgentForm>,
|
||||
avatarAction?: AgentForm['avatar_action'],
|
||||
): boolean => {
|
||||
if (avatarAction === 'reset') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!dirtyFields) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = evaluateDirtyFields(dirtyFields);
|
||||
return result.sawDirty && !result.onlyAvatarDirty;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the save may have left the stored agent different from the one it replaced,
|
||||
* across the fields the submission carried. A dirty field is no promise that anything was
|
||||
* written: the server can normalize a submission straight back to the stored value, by
|
||||
* pruning a skill that no longer exists or by dropping an MCP tool authorization rejects.
|
||||
*
|
||||
* `previous` must be the expanded agent. A basic projection omits fields the submission
|
||||
* still carries, and the update endpoint answers with their unchanged values, which would
|
||||
* read as a change that never happened. Without it the comparison cannot be trusted and
|
||||
* reports true, leaving the dirty check to decide: claiming nothing changed for a save
|
||||
* that did is the worse error of the two.
|
||||
*/
|
||||
export const mayHavePersistedChange = (
|
||||
submitted?: AgentUpdateParams,
|
||||
previous?: Agent,
|
||||
updated?: Agent,
|
||||
): boolean => {
|
||||
if (!submitted || !previous || !updated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fields = Object.keys(submitted) as Array<keyof AgentUpdateParams & keyof Agent>;
|
||||
return fields.some((field) => !isEqual(previous[field], updated[field]));
|
||||
};
|
||||
|
||||
export default function AgentPanel() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
|
|
@ -315,6 +362,8 @@ export default function AgentPanel() {
|
|||
);
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
const previousVersionRef = useRef<number | undefined>();
|
||||
const submittedDirtyRef = useRef(false);
|
||||
const submittedRef = useRef<{ payload?: AgentUpdateParams; previous?: Agent }>({});
|
||||
|
||||
const allowedProviders = useMemo(
|
||||
() => new Set(agentsConfig?.allowedProviders),
|
||||
|
|
@ -336,14 +385,27 @@ export default function AgentPanel() {
|
|||
|
||||
/* Mutations */
|
||||
const update = useUpdateAgentMutation({
|
||||
onMutate: () => {
|
||||
// Store the current version before mutation
|
||||
onMutate: (variables) => {
|
||||
/** The agent as it stands before the write, taken from the expanded query so every
|
||||
* submitted field is comparable. The mutation replaces this cache entry on success,
|
||||
* so it has to be captured here to stay comparable afterwards. */
|
||||
previousVersionRef.current = agentQuery.data?.version;
|
||||
submittedDirtyRef.current = hasPersistedDirtyFields(dirtyFields, getValues('avatar_action'));
|
||||
submittedRef.current = { payload: variables.data, previous: expandedAgentQuery.data };
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
const avatarActionState = getValues('avatar_action');
|
||||
/** An update whose result matches the newest version is written without recording a
|
||||
* version entry, so an unchanged count no longer means the save was a no-op. Only
|
||||
* a save that both carried no edit and left the agent as it found it can claim
|
||||
* nothing changed. */
|
||||
const persistedEdit =
|
||||
submittedDirtyRef.current &&
|
||||
mayHavePersistedChange(submittedRef.current.payload, submittedRef.current.previous, data);
|
||||
const noVersionChange =
|
||||
previousVersionRef.current !== undefined && data.version === previousVersionRef.current;
|
||||
!persistedEdit &&
|
||||
previousVersionRef.current !== undefined &&
|
||||
data.version === previousVersionRef.current;
|
||||
const toastMessage = getUpdateToastMessage(
|
||||
noVersionChange,
|
||||
avatarActionState,
|
||||
|
|
@ -375,8 +437,10 @@ export default function AgentPanel() {
|
|||
setValue('avatar_preview', '', { shouldDirty: false });
|
||||
}
|
||||
|
||||
// Clear the ref after use
|
||||
// Clear the refs after use
|
||||
previousVersionRef.current = undefined;
|
||||
submittedDirtyRef.current = false;
|
||||
submittedRef.current = {};
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@
|
|||
* @jest-environment jsdom
|
||||
*/
|
||||
import { Constants, type Agent } from 'librechat-data-provider';
|
||||
import type { AgentModelParameters } from 'librechat-data-provider';
|
||||
import type { FieldNamesMarkedBoolean } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
composeAgentUpdatePayload,
|
||||
persistAvatarChanges,
|
||||
isAvatarUploadOnlyDirty,
|
||||
hasPersistedDirtyFields,
|
||||
mayHavePersistedChange,
|
||||
} from '../AgentPanel';
|
||||
|
||||
const createForm = (): AgentForm => ({
|
||||
|
|
@ -166,3 +169,120 @@ describe('isAvatarUploadOnlyDirty', () => {
|
|||
expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPersistedDirtyFields', () => {
|
||||
it('returns false for an untouched form', () => {
|
||||
expect(hasPersistedDirtyFields(undefined)).toBe(false);
|
||||
expect(hasPersistedDirtyFields({} as FieldNamesMarkedBoolean<AgentForm>)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an upload-only submission, which uses its own endpoint', () => {
|
||||
const dirtyFields = {
|
||||
avatar_action: true,
|
||||
avatar_preview: true,
|
||||
} as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(hasPersistedDirtyFields(dirtyFields, 'upload')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a reset-only submission, which is sent as avatar: null', () => {
|
||||
const dirtyFields = {
|
||||
avatar_action: true,
|
||||
avatar_preview: true,
|
||||
} as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(hasPersistedDirtyFields(dirtyFields, 'reset')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for an edit the update endpoint persists', () => {
|
||||
const dirtyFields = {
|
||||
avatar_action: true,
|
||||
tools: true,
|
||||
} as unknown as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(hasPersistedDirtyFields(dirtyFields)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the agent field, which tracks selection rather than an edit', () => {
|
||||
const dirtyFields = {
|
||||
agent: { value: true },
|
||||
} as unknown as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(hasPersistedDirtyFields(dirtyFields)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mayHavePersistedChange', () => {
|
||||
const agent = (overrides: Partial<Agent> = {}): Agent =>
|
||||
({
|
||||
id: 'agent_123',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Agent',
|
||||
tools: ['a'],
|
||||
...overrides,
|
||||
}) as Agent;
|
||||
|
||||
it('returns true when anything needed for the comparison is missing', () => {
|
||||
/** Without the expanded agent the comparison cannot be trusted, and reporting no
|
||||
* change for a save that did change is the worse error. */
|
||||
expect(mayHavePersistedChange(undefined, agent(), agent())).toBe(true);
|
||||
expect(mayHavePersistedChange({ name: 'Renamed' }, undefined, agent())).toBe(true);
|
||||
expect(mayHavePersistedChange({ name: 'Renamed' }, agent(), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the stored agent came back changed', () => {
|
||||
expect(mayHavePersistedChange({ name: 'Renamed' }, agent(), agent({ name: 'Renamed' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for a reset that cleared an avatar the agent was carrying', () => {
|
||||
const previous = agent({ avatar: { filepath: '/images/a.png', source: 'local' } });
|
||||
|
||||
expect(mayHavePersistedChange({ avatar: null }, previous, agent({ avatar: null }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the server normalized the submission back to the stored value', () => {
|
||||
/** An MCP tool rejected by authorization, or a skill pruned because it no longer
|
||||
* exists, is dropped server-side and nothing is persisted. */
|
||||
const stored = agent({ tools: ['a'], skills: ['keep'] });
|
||||
|
||||
expect(
|
||||
mayHavePersistedChange(
|
||||
{ tools: ['a', 'mcp_rejected'], skills: ['keep', 'deleted'] },
|
||||
stored,
|
||||
agent({ tools: ['a'], skills: ['keep'] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('compares nested values rather than object identity', () => {
|
||||
const parameters = (temperature: number): AgentModelParameters => ({
|
||||
...createForm().model_parameters,
|
||||
temperature,
|
||||
});
|
||||
const previous = agent({ model_parameters: parameters(1) });
|
||||
|
||||
expect(
|
||||
mayHavePersistedChange(
|
||||
{ model_parameters: parameters(1) },
|
||||
previous,
|
||||
agent({ model_parameters: parameters(1) }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
mayHavePersistedChange(
|
||||
{ model_parameters: parameters(2) },
|
||||
previous,
|
||||
agent({ model_parameters: parameters(2) }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores fields the submission did not carry', () => {
|
||||
expect(
|
||||
mayHavePersistedChange({ name: 'Agent' }, agent({ description: 'before' }), agent()),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue