Repository: RasaHQ/rasa
Pull request: Fix duplicate interactive learning events
**Proposed changes**:
- fixes https://github.com/RasaHQ/rasa/issues/3676
- use RevertEvents instead of a replace to update tracker
- remove old `finetuning` stuff since it's not longer supported

**Status (please check what you already did)**:
- [x] made PR ready for code review
- [ ] added some tests for the functionality
- [ ] updated the documentation
- [x] updated the changelog
- [x] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa_nlu#code-style) for instructions)


Problem statement:
Duplicate event in SQLTracker
<!-- THIS INFORMATION IS MANDATORY - YOUR ISSUE WILL BE CLOSED IF IT IS MISSING. If you don't know your Rasa version, use `pip list | grep rasa`. Removing the below information is allowed for FEATURE REQUESTS. Please format any code or console output with the three ticks ``` above and below. -->

**Rasa version**: 1.0

**Python version**: 3.6.8

**Operating system** (windows, osx, ...): osx / postgreSQL

**Issue**: Duplicate event in SQLTracker. It seems the FLOAT precision can't handle the timestamp correctly. Actually, the field has a 5 digits precision, it needs at least 7. 

The column type should be change to numeric.

Hints from issue discussion:
Thanks for raising this issue, @wochinge  will get back to you about it soon.
This issue is also arising during interactive training since https://github.com/RasaHQ/rasa/blob/master/rasa/core/training/interactive.py#L235 expects the entire event history to be replaced (as done in the `InMemoryTrackerStore` https://github.com/RasaHQ/rasa/blob/master/rasa/core/tracker_store.py#L131) but the `SQLTrackerStore` only appends events that are after the greatest timestamp (https://github.com/RasaHQ/rasa/blob/master/rasa/core/tracker_store.py#L411) without replacing previous events
@claudio-7lieues In which setting did you see this problem?
@feynmanliang Thanks for the digging into this problem and providing the fix.
I have a look at your pr immediately.
During normal usage.

Commit message: support for appending multiple events at once to the tracker

Patch to review:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 1973f8967d94..a7b56432c48f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,6 +15,9 @@ Added
 
 Changed
 -------
+- update the tracker in interactive learning through reverting and appending events
+  instead of replacing the tracker
+- ``POST /conversations/{conversation_id}/tracker/events`` supports a list of events
 
 Removed
 -------
diff --git a/docs/_static/spec/rasa.yml b/docs/_static/spec/rasa.yml
index b9b96c58f797..e8d673b0cd5d 100644
--- a/docs/_static/spec/rasa.yml
+++ b/docs/_static/spec/rasa.yml
@@ -139,9 +139,9 @@ paths:
       - JWT: []
       tags:
       - Tracker
-      summary: Append an event to a tracker
+      summary: Append events to a tracker
       description: >-
-        Appends a new event to the tracker state of the conversation.
+        Appends one or multiple new events to the tracker state of the conversation.
         Any existing events will be kept and the new events will be
         appended, updating the existing state.
       parameters:
@@ -152,7 +152,11 @@ paths:
         content:
           application/json:
             schema:
-              $ref: '#/components/schemas/Event'
+              oneOf:
+              - $ref: '#/components/schemas/Event'
+              - type: array
+                items:
+                  $ref: '#/components/schemas/Event'
       responses:
         200:
           $ref: '#/components/responses/200Tracker'
diff --git a/rasa/cli/interactive.py b/rasa/cli/interactive.py
index 5c17371dd7fd..311670faac2d 100644
--- a/rasa/cli/interactive.py
+++ b/rasa/cli/interactive.py
@@ -44,7 +44,6 @@ def add_subparser(
 
 
 def interactive(args: argparse.Namespace):
-    args.finetune = False  # Don't support finetuning
     args.fixed_model_name = None
     args.store_uncompressed = False
 
@@ -58,7 +57,6 @@ def interactive(args: argparse.Namespace):
 
 
 def interactive_core(args: argparse.Namespace):
-    args.finetune = False  # Don't support finetuning
     args.fixed_model_name = None
     args.store_uncompressed = False
 
diff --git a/rasa/core/trackers.py b/rasa/core/trackers.py
index 34fbc6031c74..ee8e09eb0eb4 100644
--- a/rasa/core/trackers.py
+++ b/rasa/core/trackers.py
@@ -332,8 +332,8 @@ def applied_events(self) -> List[Event]:
         """Returns all actions that should be applied - w/o reverted events."""
 
         def undo_till_previous(event_type, done_events):
-            """Removes events from `done_events` until `event_type` is
-               found."""
+            """Removes events from `done_events` until the first
+               occurrence `event_type` is found which is also removed."""
             # list gets modified - hence we need to copy events!
             for e in reversed(done_events[:]):
                 del done_events[-1]
@@ -349,8 +349,8 @@ def undo_till_previous(event_type, done_events):
             elif isinstance(event, UserUtteranceReverted):
                 # Seeing a user uttered event automatically implies there was
                 # a listen event right before it, so we'll first rewind the
-                # user utterance, then get the action right before it (the
-                # listen action).
+                # user utterance, then get the action right before it (also removes
+                # the `action_listen` action right before it).
                 undo_till_previous(UserUttered, applied_events)
                 undo_till_previous(ActionExecuted, applied_events)
             else:
diff --git a/rasa/core/train.py b/rasa/core/train.py
index faf3e738cdc7..4ff0929e46e2 100644
--- a/rasa/core/train.py
+++ b/rasa/core/train.py
@@ -154,12 +154,8 @@ async def do_compare_training(cmdline_args, stories, additional_arguments):
 def do_interactive_learning(cmdline_args, stories, additional_arguments=None):
     from rasa.core.training import interactive
 
-    if cmdline_args.core and cmdline_args.finetune:
-        raise ValueError("--core can only be used without --finetune flag.")
-
     interactive.run_interactive_learning(
         stories,
-        finetune=cmdline_args.finetune,
         skip_visualization=cmdline_args.skip_visualization,
         server_args=cmdline_args.__dict__,
         additional_arguments=additional_arguments,
diff --git a/rasa/core/training/interactive.py b/rasa/core/training/interactive.py
index 8fe4d70c2003..67b6bf642d90 100644
--- a/rasa/core/training/interactive.py
+++ b/rasa/core/training/interactive.py
@@ -35,9 +35,17 @@
     REQUESTED_SLOT,
 )
 from rasa.core.domain import Domain
-from rasa.core.events import ActionExecuted, BotUttered, Event, Restarted, UserUttered
+from rasa.core.events import (
+    ActionExecuted,
+    ActionReverted,
+    BotUttered,
+    Event,
+    Restarted,
+    UserUttered,
+    UserUtteranceReverted,
+)
 from rasa.core.interpreter import INTENT_MESSAGE_PREFIX, NaturalLanguageInterpreter
-from rasa.core.trackers import EventVerbosity
+from rasa.core.trackers import EventVerbosity, DialogueStateTracker
 from rasa.core.training import visualization
 from rasa.core.training.structures import Story
 from rasa.core.training.visualization import (
@@ -218,7 +226,9 @@ async def send_action(
 
 
 async def send_event(
-    endpoint: EndpointConfig, sender_id: Text, evt: Dict[Text, Any]
+    endpoint: EndpointConfig,
+    sender_id: Text,
+    evt: Union[List[Dict[Text, Any]], Dict[Text, Any]],
 ) -> Dict[Text, Any]:
     """Log an event to a conversation."""
 
@@ -227,32 +237,14 @@ async def send_event(
     return await endpoint.request(json=evt, method="post", subpath=subpath)
 
 
-async def replace_events(
-    endpoint: EndpointConfig, sender_id: Text, evts: List[Dict[Text, Any]]
-) -> Dict[Text, Any]:
-    """Replace all the events of a conversation with the provided ones."""
-
-    subpath = "/conversations/{}/tracker/events".format(sender_id)
-
-    return await endpoint.request(json=evts, method="put", subpath=subpath)
-
-
-async def send_finetune(
-    endpoint: EndpointConfig, evts: List[Dict[Text, Any]]
-) -> Dict[Text, Any]:
-    """Finetune a core model on the provided additional training samples."""
-
-    return await endpoint.request(json=evts, method="post", subpath="/finetune")
-
-
-def format_bot_output(message: Dict[Text, Any]) -> Text:
+def format_bot_output(message: BotUttered) -> Text:
     """Format a bot response to be displayed in the history table."""
 
     # First, add text to output
-    output = message.get("text") or ""
+    output = message.text or ""
 
     # Then, append all additional items
-    data = message.get("data", {})
+    data = message.data or {}
     if not data:
         return output
 
@@ -424,6 +416,7 @@ async def _request_intent_from_user(
         choices, sender_id, endpoint
     )
 
+    # TODO: Should we not always set the confidence = 1.0?
     if intent_name == OTHER_INTENT:
         intent_name = await _request_free_text_intent(sender_id, endpoint)
         return {"name": intent_name, "confidence": 1.0}
@@ -459,21 +452,21 @@ def _chat_history_table(evts: List[Dict[Text, Any]]) -> Text:
     Also includes additional information, like any events and
     prediction probabilities."""
 
-    def wrap(txt, max_width):
+    def wrap(txt: Text, max_width: float) -> Text:
         return "\n".join(textwrap.wrap(txt, max_width, replace_whitespace=False))
 
-    def colored(txt, color):
+    def colored(txt: Text, color: Text) -> Text:
         return "{" + color + "}" + txt + "{/" + color + "}"
 
-    def format_user_msg(user_evt, max_width):
-        _parsed = user_evt.get("parse_data", {})
-        _intent = _parsed.get("intent", {}).get("name")
-        _confidence = _parsed.get("intent", {}).get("confidence", 1.0)
-        _md = _as_md_message(_parsed)
+    def format_user_msg(user_event: UserUttered, max_width: float) -> Text:
+        intent = user_event.intent or {}
+        intent_name = intent.get("name", "")
+        _confidence = intent.get("confidence", 1.0)
+        _md = _as_md_message(user_event.parse_data)
 
         _lines = [
             colored(wrap(_md, max_width), "hired"),
-            "intent: {} {:03.2f}".format(_intent, _confidence),
+            "intent: {} {:03.2f}".format(intent_name, _confidence),
         ]
         return "\n".join(_lines)
 
@@ -503,31 +496,34 @@ def add_user_cell(data, cell):
     table = SingleTable(table_data, "Chat History")
 
     bot_column = []
-    for idx, evt in enumerate(evts):
-        if evt.get("event") == ActionExecuted.type_name:
-            bot_column.append(colored(evt["name"], "autocyan"))
-            if evt["confidence"] is not None:
+
+    tracker = DialogueStateTracker.from_dict("any", evts)
+    applied_events = tracker.applied_events()
+
+    for idx, event in enumerate(applied_events):
+        if isinstance(event, ActionExecuted):
+            bot_column.append(colored(event.action_name, "autocyan"))
+            if event.confidence is not None:
                 bot_column[-1] += colored(
-                    " {:03.2f}".format(evt["confidence"]), "autowhite"
+                    " {:03.2f}".format(event.confidence), "autowhite"
                 )
 
-        elif evt.get("event") == UserUttered.type_name:
+        elif isinstance(event, UserUttered):
             if bot_column:
                 text = "\n".join(bot_column)
                 add_bot_cell(table_data, text)
                 bot_column = []
 
-            msg = format_user_msg(evt, user_width(table))
+            msg = format_user_msg(event, user_width(table))
             add_user_cell(table_data, msg)
 
-        elif evt.get("event") == BotUttered.type_name:
-            wrapped = wrap(format_bot_output(evt), bot_width(table))
+        elif isinstance(event, BotUttered):
+            wrapped = wrap(format_bot_output(event), bot_width(table))
             bot_column.append(colored(wrapped, "autoblue"))
 
         else:
-            e = Event.from_parameters(evt)
-            if e.as_story_string():
-                bot_column.append(wrap(e.as_story_string(), bot_width(table)))
+            if event.as_story_string():
+                bot_column.append(wrap(event.as_story_string(), bot_width(table)))
 
     if bot_column:
         text = "\n".join(bot_column)
@@ -862,7 +858,6 @@ async def _write_domain_to_file(
 async def _predict_till_next_listen(
     endpoint: EndpointConfig,
     sender_id: Text,
-    finetune: bool,
     sender_ids: List[Text],
     plot_file: Optional[Text],
 ) -> None:
@@ -884,13 +879,7 @@ async def _predict_till_next_listen(
         )
 
         listen = await _validate_action(
-            action_name,
-            policy,
-            confidence,
-            predictions,
-            endpoint,
-            sender_id,
-            finetune=finetune,
+            action_name, policy, confidence, predictions, endpoint, sender_id
         )
 
         await _plot_trackers(sender_ids, plot_file, endpoint)
@@ -919,24 +908,20 @@ async def _predict_till_next_listen(
 
 async def _correct_wrong_nlu(
     corrected_nlu: Dict[Text, Any],
-    evts: List[Dict[Text, Any]],
+    events: List[Dict[Text, Any]],
     endpoint: EndpointConfig,
     sender_id: Text,
 ) -> None:
     """A wrong NLU prediction got corrected, update core's tracker."""
 
-    latest_message = latest_user_message(evts)
-    corrected_events = all_events_before_latest_user_msg(evts)
-
-    latest_message["parse_data"] = corrected_nlu
-
-    await replace_events(endpoint, sender_id, corrected_events)
-
-    await send_message(
+    revert_latest_user_utterance = UserUtteranceReverted().as_dict()
+    listen_for_next_message = ActionExecuted(ACTION_LISTEN_NAME).as_dict()
+    corrected_message = latest_user_message(events)
+    corrected_message["parse_data"] = corrected_nlu
+    await send_event(
         endpoint,
         sender_id,
-        latest_message.get("text"),
-        latest_message.get("parse_data"),
+        [revert_latest_user_utterance, listen_for_next_message, corrected_message],
     )
 
 
@@ -944,18 +929,14 @@ async def _correct_wrong_action(
     corrected_action: Text,
     endpoint: EndpointConfig,
     sender_id: Text,
-    finetune: bool = False,
     is_new_action: bool = False,
 ) -> None:
     """A wrong action prediction got corrected, update core's tracker."""
 
-    result = await send_action(
+    _ = await send_action(
         endpoint, sender_id, corrected_action, is_new_action=is_new_action
     )
 
-    if finetune:
-        await send_finetune(endpoint, result.get("tracker", {}).get("events", []))
-
 
 def _form_is_rejected(action_name, tracker):
     """Check if the form got rejected with the most recent action name."""
@@ -1019,7 +1000,6 @@ async def _validate_action(
     predictions: List[Dict[Text, Any]],
     endpoint: EndpointConfig,
     sender_id: Text,
-    finetune: bool = False,
 ) -> bool:
     """Query the user to validate if an action prediction is correct.
 
@@ -1056,11 +1036,7 @@ async def _validate_action(
 
     if not is_correct:
         await _correct_wrong_action(
-            action_name,
-            endpoint,
-            sender_id,
-            finetune=finetune,
-            is_new_action=is_new_action,
+            action_name, endpoint, sender_id, is_new_action=is_new_action
         )
     else:
         await send_action(endpoint, sender_id, action_name, policy, confidence)
@@ -1233,20 +1209,25 @@ async def _undo_latest(sender_id: Text, endpoint: EndpointConfig) -> None:
 
     tracker = await retrieve_tracker(endpoint, sender_id, EventVerbosity.ALL)
 
-    cutoff_index = None
+    # Get latest `UserUtterance` or `ActionExecuted` event.
+    last_event_type = None
     for i, e in enumerate(reversed(tracker.get("events", []))):
-        if e.get("event") in {ActionExecuted.type_name, UserUttered.type_name}:
-            cutoff_index = i
+        last_event_type = e.get("event")
+        if last_event_type in {ActionExecuted.type_name, UserUttered.type_name}:
             break
-        elif e.get("event") == Restarted.type_name:
+        elif last_event_type == Restarted.type_name:
             break
 
-    if cutoff_index is not None:
-        events_to_keep = tracker["events"][: -(cutoff_index + 1)]
+    if last_event_type == ActionExecuted.type_name:
+        undo_action = ActionReverted().as_dict()
+        await send_event(endpoint, sender_id, undo_action)
+    elif last_event_type == UserUttered.type_name:
+        undo_user_message = UserUtteranceReverted().as_dict()
+        listen_for_next_message = ActionExecuted(ACTION_LISTEN_NAME).as_dict()
 
-        # reset the events of the conversation to the events before
-        # the most recent bot or user event
-        await replace_events(endpoint, sender_id, events_to_keep)
+        await send_event(
+            endpoint, sender_id, [undo_user_message, listen_for_next_message]
+        )
 
 
 async def _fetch_events(
@@ -1323,7 +1304,6 @@ async def record_messages(
     endpoint: EndpointConfig,
     sender_id: Text = UserMessage.DEFAULT_SENDER_ID,
     max_message_limit: Optional[int] = None,
-    finetune: bool = False,
     stories: Optional[Text] = None,
     skip_visualization: bool = False,
 ):
@@ -1367,7 +1347,7 @@ async def record_messages(
                     await _enter_user_message(sender_id, endpoint)
                     await _validate_nlu(intents, endpoint, sender_id)
                 await _predict_till_next_listen(
-                    endpoint, sender_id, finetune, sender_ids, plot_file
+                    endpoint, sender_id, sender_ids, plot_file
                 )
 
                 num_messages += 1
@@ -1404,7 +1384,7 @@ async def record_messages(
         raise
 
 
-def _serve_application(app, stories, finetune, skip_visualization):
+def _serve_application(app, stories, skip_visualization):
     """Start a core server and attach the interactive learning IO."""
 
     endpoint = EndpointConfig(url=DEFAULT_SERVER_URL)
@@ -1415,7 +1395,6 @@ async def run_interactive_io(running_app: Sanic):
         await record_messages(
             endpoint=endpoint,
             stories=stories,
-            finetune=finetune,
             skip_visualization=skip_visualization,
             sender_id=uuid.uuid4().hex,
         )
@@ -1508,7 +1487,6 @@ async def wait_til_server_is_running(endpoint, max_retries=30, sleep_between_ret
 
 def run_interactive_learning(
     stories: Text = None,
-    finetune: bool = False,
     skip_visualization: bool = False,
     server_args: Dict[Text, Any] = None,
     additional_arguments: Dict[Text, Any] = None,
@@ -1549,7 +1527,7 @@ def run_interactive_learning(
             "before_server_start",
         )
 
-    _serve_application(app, stories, finetune, skip_visualization)
+    _serve_application(app, stories, skip_visualization)
 
     if not skip_visualization:
         p.terminate()
diff --git a/rasa/server.py b/rasa/server.py
index b1847c7fdbfe..f3f882200f43 100644
--- a/rasa/server.py
+++ b/rasa/server.py
@@ -353,7 +353,7 @@ async def retrieve_tracker(request: Request, conversation_id: Text):
     @app.post("/conversations/<conversation_id>/tracker/events")
     @requires_auth(app, auth_token)
     @ensure_loaded_agent(app)
-    async def append_event(request: Request, conversation_id: Text):
+    async def append_events(request: Request, conversation_id: Text):
         """Append a list of events to the state of a conversation"""
         validate_request_body(
             request,
@@ -361,34 +361,42 @@ async def append_event(request: Request, conversation_id: Text):
             "to the state of a conversation.",
         )
 
-        evt = Event.from_parameters(request.json)
-        verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART)
+        events = request.json
+        if not isinstance(events, list):
+            events = [events]
+
+        events = [Event.from_parameters(event) for event in events]
+        events = [event for event in events if event]
 
+        if not events:
+            logger.warning(
+                "Append event called, but could not extract a valid event. "
+                "Request JSON: {}".format(request.json)
+            )
+            raise ErrorResponse(
+                400,
+                "BadRequest",
+                "Couldn't extract a proper event from the request body.",
+                {"parameter": "", "in": "body"},
+            )
+
+        verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART)
         tracker = obtain_tracker_store(app.agent, conversation_id)
 
-        if evt:
-            try:
-                tracker.update(evt, app.agent.domain)
-                app.agent.tracker_store.save(tracker)
-                return response.json(tracker.current_state(verbosity))
-            except Exception as e:
-                logger.debug(traceback.format_exc())
-                raise ErrorResponse(
-                    500,
-                    "ConversationError",
-                    "An unexpected error occurred. Error: {}".format(e),
-                )
+        try:
+            for event in events:
+                tracker.update(event, app.agent.domain)
 
-        logger.warning(
-            "Append event called, but could not extract a valid event. "
-            "Request JSON: {}".format(request.json)
-        )
-        raise ErrorResponse(
-            400,
-            "BadRequest",
-            "Couldn't extract a proper event from the request body.",
-            {"parameter": "", "in": "body"},
-        )
+            app.agent.tracker_store.save(tracker)
+
+            return response.json(tracker.current_state(verbosity))
+        except Exception as e:
+            logger.debug(traceback.format_exc())
+            raise ErrorResponse(
+                500,
+                "ConversationError",
+                "An unexpected error occurred. Error: {}".format(e),
+            )
 
     @app.put("/conversations/<conversation_id>/tracker/events")
     @requires_auth(app, auth_token)
diff --git a/tests/core/test_interactive.py b/tests/core/test_interactive.py
index 3eb5281eecd9..6343a377f00b 100644
--- a/tests/core/test_interactive.py
+++ b/tests/core/test_interactive.py
@@ -4,6 +4,7 @@
 from aioresponses import aioresponses
 
 import rasa.utils.io
+from rasa.core.events import BotUttered
 from rasa.core.training import interactive
 from rasa.utils.endpoints import EndpointConfig
 from rasa.core.actions.action import default_actions
@@ -48,6 +49,7 @@ async def test_request_prediction(mock_endpoint):
 
 def test_bot_output_format():
     message = {
+        "event": "bot",
         "text": "Hello!",
         "data": {
             "image": "http://example.com/myimage.png",
@@ -78,7 +80,13 @@ def test_bot_output_format():
             ],
         },
     }
-    formatted = interactive.format_bot_output(message)
+    from rasa.core.events import Event
+
+    bot_event = Event.from_parameters(message)
+
+    assert isinstance(bot_event, BotUttered)
+
+    formatted = interactive.format_bot_output(bot_event)
     assert formatted == (
         "Hello!\n"
         "Image: http://example.com/myimage.png\n"
@@ -242,32 +250,29 @@ def test_validate_user_message():
 
 async def test_undo_latest_msg(mock_endpoint):
     tracker_dump = rasa.utils.io.read_file("data/test_trackers/tracker_moodbot.json")
-    tracker_json = json.loads(tracker_dump)
-    evts = tracker_json.get("events")
 
     sender_id = uuid.uuid4().hex
 
     url = "{}/conversations/{}/tracker?include_events=ALL".format(
         mock_endpoint.url, sender_id
     )
-    replace_url = "{}/conversations/{}/tracker/events".format(
+    append_url = "{}/conversations/{}/tracker/events".format(
         mock_endpoint.url, sender_id
     )
     with aioresponses() as mocked:
         mocked.get(url, body=tracker_dump)
-        mocked.put(replace_url)
+        mocked.post(append_url)
 
         await interactive._undo_latest(sender_id, mock_endpoint)
 
-        r = latest_request(mocked, "put", replace_url)
+        r = latest_request(mocked, "post", append_url)
 
         assert r
 
         # this should be the events the interactive call send to the endpoint
         # these events should have the last utterance omitted
-        replaced_evts = json_of_latest_request(r)
-        assert len(replaced_evts) == 6
-        assert replaced_evts == evts[:6]
+        corrected_event = json_of_latest_request(r)
+        assert corrected_event["event"] == "undo"
 
 
 def test_utter_custom_message():
diff --git a/tests/test_server.py b/tests/test_server.py
index 8687941b9e94..c0fc945bda32 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -478,6 +478,29 @@ def test_pushing_event(rasa_app, event):
     assert Event.from_parameters(evt) == event
 
 
+def test_push_multiple_events(rasa_app):
+    cid = str(uuid.uuid1())
+    conversation = "/conversations/{}".format(cid)
+
+    events = [e.as_dict() for e in test_events]
+    data = json.dumps(events)
+    _, response = rasa_app.post(
+        "{}/tracker/events".format(conversation),
+        data=data,
+        headers={"Content-Type": "application/json"},
+    )
+    assert response.json is not None
+    assert response.status == 200
+
+    _, tracker_response = rasa_app.get("/conversations/{}/tracker".format(cid))
+    tracker = tracker_response.json
+    assert tracker is not None
+
+    # there is also an `ACTION_LISTEN` event at the start
+    assert len(tracker.get("events")) == len(test_events) + 1
+    assert tracker.get("events")[1:] == events
+
+
 def test_put_tracker(rasa_app):
     data = json.dumps([event.as_dict() for event in test_events])
     _, response = rasa_app.put(
@@ -563,7 +586,7 @@ def test_list_routes(default_agent):
         "version",
         "status",
         "retrieve_tracker",
-        "append_event",
+        "append_events",
         "replace_events",
         "retrieve_story",
         "execute_action",


Code review comments:
diff_hunk: @@ -503,31 +496,34 @@ def add_user_cell(data, cell):
     table = SingleTable(table_data, "Chat History")
 
     bot_column = []
-    for idx, evt in enumerate(evts):
-        if evt.get("event") == ActionExecuted.type_name:
-            bot_column.append(colored(evt["name"], "autocyan"))
-            if evt["confidence"] is not None:
+
+    tracker = DialogueStateTracker.from_dict("any", evts)
path: rasa/core/training/interactive.py
text: @user1:
Can we also rename `evts` to `events`?

diff_hunk: @@ -919,43 +908,35 @@ def _intents_from_messages(messages):
 
 async def _correct_wrong_nlu(
     corrected_nlu: Dict[Text, Any],
-    evts: List[Dict[Text, Any]],
+    events: List[Dict[Text, Any]],
     endpoint: EndpointConfig,
     sender_id: Text,
 ) -> None:
     """A wrong NLU prediction got corrected, update core's tracker."""
 
-    latest_message = latest_user_message(evts)
-    corrected_events = all_events_before_latest_user_msg(evts)
-
-    latest_message["parse_data"] = corrected_nlu
-
-    await replace_events(endpoint, sender_id, corrected_events)
-
-    await send_message(
+    revert_latest_user_utterance = UserUtteranceReverted().as_dict()
+    listen_for_next_message = ActionExecuted(ACTION_LISTEN_NAME).as_dict()
+    corrected_message = latest_user_message(events)
+    corrected_message["parse_data"] = corrected_nlu
+    await send_event(
         endpoint,
         sender_id,
-        latest_message.get("text"),
-        latest_message.get("parse_data"),
+        [revert_latest_user_utterance, listen_for_next_message, corrected_message],
     )
 
 
 async def _correct_wrong_action(
     corrected_action: Text,
     endpoint: EndpointConfig,
     sender_id: Text,
-    finetune: bool = False,
     is_new_action: bool = False,
 ) -> None:
     """A wrong action prediction got corrected, update core's tracker."""
 
-    result = await send_action(
+    _ = await send_action(
path: rasa/core/training/interactive.py
text: @user1:
```suggestion
    await send_action(
```

@author:
Mhm, I did it so it's clear that I am omitting a result there. But since I didn't find clear guidelines for it, I will revert it 👍