CortexAssistantApiModule

Common utilities and classes for integrating with Cortex Assistant (AgentiX). Provides platform-agnostic functionality that can be used across different integrations (Slack, Teams, etc.).

python · ApiModules

Details

IDCortexAssistantApiModule
Languagepython
From Version8.14.0
Docker Imagedemisto/python3:3.10.14.95956
import pytest
from datetime import datetime, UTC

from pytest_mock import MockerFixture
import demistomock as demisto
from CortexAssistantApiModule import (
    BackendErrorCode,
    BackendErrorType,
    BackendResponse,
    AssistantStatus,
    AssistantMessageType,
    AssistantActionIds,
    AssistantMessages,
    AssistantMessagingHandler,
)


# ============================================================================
# Mock Handler for Testing
# ============================================================================


class MockMessagingHandler(AssistantMessagingHandler):
    """Mock implementation for testing platform-agnostic logic."""

    def __init__(self):
        super().__init__()
        self.sent_messages = []
        self.updated_messages = []
        self.deleted_messages = []

    async def send_message_async(self, channel_id: str, message: str, thread_id: str = "",
                                  blocks: list | None = None, attachments: list | None = None,
                                  ephemeral: bool = False, user_id: str = ""):
        self.sent_messages.append({"channel_id": channel_id, "message": message, "ephemeral": ephemeral})
        return {"ts": "1234567890.123456"}

    async def update_message(self, channel_id: str, message_id: str, text: str = "", blocks: list | None = None):
        self.updated_messages.append({"channel_id": channel_id, "message_id": message_id, "text": text, "blocks": blocks})

    def delete_message(self, channel_id: str, message_id: str) -> tuple[bool, dict]:
        self.deleted_messages.append({"channel_id": channel_id, "message_id": message_id})
        return True, {"ok": True}

    async def get_user_info(self, user_id: str) -> dict:
        return {"id": user_id, "email": "test@example.com"}

    async def get_thread_last_messages(self, channel_id: str, thread_id: str, limit: int = 20) -> list:
        return []

    def format_user_mention(self, user_id: str) -> str:
        return f"<@{user_id}>"

    def normalize_message_from_user(self, text: str) -> str:
        return text

    def prepare_message_blocks(self, message: str, message_type: str, is_update: bool = False) -> tuple:
        return ([], [])

    def prepare_merged_step_blocks(self, step_contents: list[str]) -> tuple[list, list]:
        return ([{"type": "merged_steps", "contents": step_contents}], [])

    def create_agent_selection_ui(self, agents: list) -> list:
        return [{"type": "section"}] if agents else []

    def create_approval_ui(self) -> list:
        return [{"type": "actions"}]

    def create_feedback_ui(self, message_id: str) -> dict:
        return {"type": "actions"}

    def post_agent_response(self, channel_id: str, thread_id: str, blocks: list,
                                  attachments: list, agent_name: str = "") -> dict | None:
        self.last_posted_blocks = blocks
        return {"ts": "1234567890.123456"}

    def update_existing_message(self, channel_id: str, thread_id: str, message_id: str,
                                attachments: list) -> bool:
        return True

    def finalize_plan_header(self, channel_id: str, thread_id: str, step_message_id: str):
        pass

    def update_context(self, context_updates: dict):
        pass

    async def show_feedback_modal(self, trigger_id: str, message_id: str, channel_id: str, thread_id: str):
        pass

    async def get_conversation_context_formatted(self, channel_id: str, thread_id: str,
                                                  bot_id: str, current_message_id: str,
                                                  max_context_messages: int = 5) -> str:
        return ""


# ============================================================================
# Test BackendErrorType
# ============================================================================


def test_backend_error_type_values():
    """
    Given:
        BackendErrorType enum.
    When:
        Accessing enum values.
    Then:
        All error types have correct string values.
    """
    assert BackendErrorType.LLM_NOT_ENABLED.value == "llm_not_enabled"
    assert BackendErrorType.USER_NOT_FOUND.value == "user_not_found"
    assert BackendErrorType.PERMISSION_DENIED.value == "permission_denied"
    assert BackendErrorType.CONVERSATION_NOT_FOUND.value == "conversation_not_found"
    assert BackendErrorType.WRONG_USER.value == "wrong_user"
    assert BackendErrorType.UNKNOWN.value == "unknown"


# ============================================================================
# Test BackendResponse
# ============================================================================


def test_backend_response_success():
    """
    Given:
        Success parameters.
    When:
        Creating a BackendResponse.
    Then:
        Response indicates success with no errors.
    """
    response = BackendResponse(success=True)
    assert response.success is True
    assert response.error_type is None
    assert response.error_message is None


def test_backend_response_failure():
    """
    Given:
        Failure parameters with error details.
    When:
        Creating a BackendResponse.
    Then:
        Response contains error type and message.
    """
    response = BackendResponse(
        success=False,
        error_type=BackendErrorType.USER_NOT_FOUND,
        error_message="User does not exist"
    )
    assert response.success is False
    assert response.error_type == BackendErrorType.USER_NOT_FOUND
    assert response.error_message == "User does not exist"


# ============================================================================
# Test AssistantStatus
# ============================================================================


def test_assistant_status_is_awaiting_user_action():
    """
    Given:
        Different status values.
    When:
        Checking if status is awaiting user action.
    Then:
        Returns True only for agent selection and approval statuses.
    """
    assert AssistantStatus.is_awaiting_user_action(AssistantStatus.AWAITING_AGENT_SELECTION.value) is True
    assert AssistantStatus.is_awaiting_user_action(AssistantStatus.AWAITING_SENSITIVE_ACTION_APPROVAL.value) is True
    assert AssistantStatus.is_awaiting_user_action(AssistantStatus.AWAITING_BACKEND_RESPONSE.value) is False
    assert AssistantStatus.is_awaiting_user_action(AssistantStatus.RESPONDING_WITH_PLAN.value) is False


def test_assistant_status_get_timeout():
    """
    Given:
        Different status values.
    When:
        Getting timeout for each status.
    Then:
        Returns correct timeout duration in seconds.
    """
    assert AssistantStatus.get_timeout_for_status(AssistantStatus.AWAITING_BACKEND_RESPONSE.value) == 60
    assert AssistantStatus.get_timeout_for_status(AssistantStatus.RESPONDING_WITH_PLAN.value) == 300
    assert AssistantStatus.get_timeout_for_status(AssistantStatus.AWAITING_AGENT_SELECTION.value) == 604800
    assert AssistantStatus.get_timeout_for_status(AssistantStatus.AWAITING_SENSITIVE_ACTION_APPROVAL.value) == 1209600
    assert AssistantStatus.get_timeout_for_status("invalid") == 0


def test_assistant_status_is_expired():
    """
    Given:
        Status and last_updated timestamp.
    When:
        Checking if conversation is expired.
    Then:
        Returns True if time elapsed exceeds timeout.
    """
    current_time = datetime.now(UTC).timestamp()
    
    # Not expired - updated 30 seconds ago
    last_updated = current_time - 30
    assert AssistantStatus.is_expired(AssistantStatus.AWAITING_BACKEND_RESPONSE.value, last_updated) is False
    
    # Expired - updated 2 minutes ago (timeout is 1 minute)
    last_updated = current_time - 120
    assert AssistantStatus.is_expired(AssistantStatus.AWAITING_BACKEND_RESPONSE.value, last_updated) is True


# ============================================================================
# Test AssistantMessageType
# ============================================================================


def test_assistant_message_type_is_model_type():
    """
    Given:
        Different message types.
    When:
        Checking if message type is a model type.
    Then:
        Returns True for model, clarification, copilot, script, and approval types.
    """
    assert AssistantMessageType.is_model_type(AssistantMessageType.MODEL.value) is True
    assert AssistantMessageType.is_model_type(AssistantMessageType.CLARIFICATION.value) is True
    assert AssistantMessageType.is_model_type(AssistantMessageType.APPROVAL.value) is True
    assert AssistantMessageType.is_model_type(AssistantMessageType.STEP.value) is False
    assert AssistantMessageType.is_model_type(AssistantMessageType.ERROR.value) is False


def test_assistant_message_type_is_step_type():
    """
    Given:
        Different message types.
    When:
        Checking if message type is a step type.
    Then:
        Returns True for step and thought types.
    """
    assert AssistantMessageType.is_step_type(AssistantMessageType.STEP.value) is True
    assert AssistantMessageType.is_step_type(AssistantMessageType.THOUGHT.value) is True
    assert AssistantMessageType.is_step_type(AssistantMessageType.MODEL.value) is False


def test_assistant_message_type_is_approval_type():
    """
    Given:
        Different message types.
    When:
        Checking if message type requires approval.
    Then:
        Returns True only for approval type.
    """
    assert AssistantMessageType.is_approval_type(AssistantMessageType.APPROVAL.value) is True
    assert AssistantMessageType.is_approval_type(AssistantMessageType.MODEL.value) is False


def test_assistant_message_type_is_error_type():
    """
    Given:
        Different message types.
    When:
        Checking if message type is an error.
    Then:
        Returns True only for error type.
    """
    assert AssistantMessageType.is_error_type(AssistantMessageType.ERROR.value) is True
    assert AssistantMessageType.is_error_type(AssistantMessageType.MODEL.value) is False


# ============================================================================
# Test AssistantMessagingHandler
# ============================================================================


def test_cleanup_expired_conversations_empty():
    """
    Given:
        Empty assistant context.
    When:
        Cleaning up expired conversations.
    Then:
        Returns empty dictionary.
    """
    handler = MockMessagingHandler()
    result = handler.delete_expired_conversations({})
    assert result == {}


def test_cleanup_expired_conversations_removes_expired():
    """
    Given:
        Assistant context with expired and active conversations.
    When:
        Cleaning up expired conversations.
    Then:
        Removes only expired conversations.
    """
    handler = MockMessagingHandler()
    current_time = datetime.now(UTC).timestamp()
    
    assistant = {
        "expired": {
            "status": AssistantStatus.AWAITING_BACKEND_RESPONSE.value,
            "last_updated": current_time - 120  # 2 minutes ago (timeout is 1 min)
        },
        "active": {
            "status": AssistantStatus.AWAITING_BACKEND_RESPONSE.value,
            "last_updated": current_time - 30  # 30 seconds ago
        }
    }
    
    result = handler.delete_expired_conversations(assistant)
    assert "expired" not in result
    assert "active" in result


def test_handle_backend_response_success(mocker: MockerFixture):
    """
    Given:
        Successful backend response.
    When:
        Handling backend response.
    Then:
        Returns BackendResponse with success=True.
    """
    mocker.patch.object(demisto, "debug")
    handler = MockMessagingHandler()
    
    response = {"success": True}
    result = handler.handle_backend_response(response, "test_operation")
    
    assert result.success is True
    assert result.error_type is None


@pytest.mark.parametrize("error_code,expected_type", [
    (BackendErrorCode.LLM_NOT_ENABLED, BackendErrorType.LLM_NOT_ENABLED),
    (BackendErrorCode.USER_NOT_FOUND, BackendErrorType.USER_NOT_FOUND),
    (BackendErrorCode.PERMISSION_DENIED, BackendErrorType.PERMISSION_DENIED),
    (BackendErrorCode.CONVERSATION_NOT_FOUND, BackendErrorType.CONVERSATION_NOT_FOUND),
    (BackendErrorCode.WRONG_USER, BackendErrorType.WRONG_USER),
])
def test_handle_backend_response_errors(mocker: MockerFixture, error_code, expected_type):
    """
    Given:
        Backend response with specific error code.
    When:
        Handling backend response.
    Then:
        Returns BackendResponse with correct error type.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "error")
    handler = MockMessagingHandler()
    
    response = {"error_code": error_code, "error": "Test error"}
    result = handler.handle_backend_response(response, "test_operation")
    
    assert result.success is False
    assert result.error_type == expected_type


def test_format_context_messages_empty():
    """
    Given:
        Empty message list.
    When:
        Formatting context messages.
    Then:
        Returns empty string.
    """
    handler = MockMessagingHandler()
    result = handler.format_context_messages([])
    assert result == ""


def test_format_context_messages_multiple():
    """
    Given:
        Multiple context messages.
    When:
        Formatting context messages.
    Then:
        Returns formatted string with context headers and messages in reverse order.
    """
    handler = MockMessagingHandler()
    messages = [
        {"user": "Alice", "text": "Hello"},
        {"user": "Bob", "text": "Hi there"}
    ]
    result = handler.format_context_messages(messages)
    
    assert AssistantMessages.CONTEXT_START in result
    assert AssistantMessages.CONTEXT_END in result
    assert "**Alice**: Hello" in result
    assert "**Bob**: Hi there" in result


@pytest.mark.asyncio
async def test_submit_feedback_positive(mocker: MockerFixture):
    """
    Given:
        Positive feedback parameters.
    When:
        Submitting feedback to backend.
    Then:
        Calls agentixCommands with is_liked=True.
    """
    handler = MockMessagingHandler()
    mocker.patch.object(demisto, "agentixCommands", return_value={"success": True})
    
    result = await handler.submit_feedback(
        message_id="msg123",
        is_positive=True,
        thread_id="thread123",
        channel_id="channel123",
        username="user@example.com"
    )
    
    assert result.success is True
    call_args = demisto.agentixCommands.call_args[0]
    assert call_args[0] == "rateMessage"
    assert call_args[1]["is_liked"] is True


@pytest.mark.asyncio
async def test_handle_reset_session_not_reset_command(mocker: MockerFixture):
    """
    Given:
        Message that is not a reset command.
    When:
        Handling reset session.
    Then:
        Returns False and unchanged assistant context.
    """
    handler = MockMessagingHandler()
    
    is_reset, assistant = await handler.handle_reset_session(
        text="<@BOT123> hello",
        user_id="user123",
        channel_id="channel123",
        thread_id="thread123",
        assistant={},
        assistant_id_key="conv1",
        bot_id="BOT123",
        user_email="user@example.com"
    )
    
    assert is_reset is False


@pytest.mark.asyncio
async def test_handle_reset_session_agent_selection(mocker: MockerFixture):
    """
    Given:
        Reset command during agent selection status.
    When:
        Handling reset session.
    Then:
        Removes conversation from context and sends success message.
    """
    handler = MockMessagingHandler()
    assistant = {
        "conv1": {
            "status": AssistantStatus.AWAITING_AGENT_SELECTION.value,
            "user": "user123"
        }
    }
    
    is_reset, result_assistant = await handler.handle_reset_session(
        text="<@BOT123> !reset",
        user_id="user123",
        channel_id="channel123",
        thread_id="thread123",
        assistant=assistant,
        assistant_id_key="conv1",
        bot_id="BOT123",
        user_email="user@example.com"
    )
    
    assert is_reset is True
    assert "conv1" not in result_assistant
    assert len(handler.sent_messages) == 1
    assert AssistantMessages.RESET_SESSION_SUCCESS in handler.sent_messages[0]["message"]


@pytest.mark.asyncio
async def test_handle_reset_session_processing(mocker: MockerFixture):
    """
    Given:
        Reset command while processing.
    When:
        Handling reset session.
    Then:
        Keeps conversation and sends cannot reset message.
    """
    handler = MockMessagingHandler()
    assistant = {
        "conv1": {
            "status": AssistantStatus.AWAITING_BACKEND_RESPONSE.value,
            "user": "user123"
        }
    }
    
    is_reset, result_assistant = await handler.handle_reset_session(
        text="<@BOT123> !reset",
        user_id="user123",
        channel_id="channel123",
        thread_id="thread123",
        assistant=assistant,
        assistant_id_key="conv1",
        bot_id="BOT123",
        user_email="user@example.com"
    )
    
    assert is_reset is True
    assert "conv1" in result_assistant
    assert AssistantMessages.RESET_SESSION_CANNOT_RESET_PROCESSING in handler.sent_messages[0]["message"]


def test_send_agent_response_invalid_message_type(mocker: MockerFixture):
    """
    Given:
        Invalid response_type in messages list.
    When:
        Sending agent response.
    Then:
        Raises ValueError.
    """
    handler = MockMessagingHandler()

    with pytest.raises(ValueError) as exc_info:
        handler.send_agent_response(
            channel_id="channel123",
            thread_id="thread123",
            messages=[{"content": "Test", "response_type": "invalid_type", "is_final": False}],
            assistant_context={},
            assistant_id_key="conv1",
        )

    assert "Invalid response_type" in str(exc_info.value)


def test_send_agent_response_model_completed(mocker: MockerFixture):
    """
    Given:
        Model message with is_final=True.
    When:
        Sending agent response.
    Then:
        Releases lock by removing conversation from context.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    result = handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Answer", "response_type": AssistantMessageType.MODEL.value, "is_final": True}],
        assistant_context=assistant,
        assistant_id_key="conv1",
    )

    assert "conv1" not in result


def test_send_agent_response_approval_type(mocker: MockerFixture):
    """
    Given:
        Approval message type.
    When:
        Sending agent response.
    Then:
        Updates status to awaiting sensitive action approval.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {}}

    result = handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Approve?", "response_type": AssistantMessageType.APPROVAL.value, "is_final": False}],
        assistant_context=assistant,
        assistant_id_key="conv1",
    )

    assert result["conv1"]["status"] == AssistantStatus.AWAITING_SENSITIVE_ACTION_APPROVAL.value


def test_send_agent_response_error_releases_lock(mocker: MockerFixture):
    """
    Given:
        Error message type.
    When:
        Sending agent response.
    Then:
        Immediately releases lock.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    result = handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Error occurred", "response_type": AssistantMessageType.ERROR.value, "is_final": True}],
        assistant_context=assistant,
        assistant_id_key="conv1",
    )

    assert "conv1" not in result


def test_send_agent_response_error_adds_user_mention(mocker: MockerFixture):
    """
    Given:
    	Error message type with a user_id provided.
    When:
    	Sending agent response.
    Then:
    	Inserts a user mention block at the beginning of the blocks list.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Request can't be processed at the moment", "response_type": AssistantMessageType.ERROR.value, "is_final": True}],
        assistant_context=assistant,
        assistant_id_key="conv1",
        user_id="U123",
    )

    assert handler.last_posted_blocks[0] == {
        "type": "section",
        "text": {"type": "mrkdwn", "text": "<@U123>"},
    }


def test_send_agent_response_deletes_thinking_indicator(mocker: MockerFixture):
    """
    Given:
        Assistant context with thinking_message_id.
    When:
        Sending agent response.
    Then:
        Deletes thinking indicator message.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"thinking_message_id": "1234567890.123456"}}

    handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Response", "response_type": AssistantMessageType.MODEL.value, "is_final": True}],
        assistant_context=assistant,
        assistant_id_key="conv1",
    )

    assert len(handler.deleted_messages) == 1
    assert handler.deleted_messages[0]["message_id"] == "1234567890.123456"


def test_send_agent_response_model_adds_user_mention(mocker: MockerFixture):
    """
    Given:
        Model message type with a user_id provided.
    When:
        Sending agent response.
    Then:
        Inserts a user mention block at the beginning of the blocks list.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Answer", "response_type": AssistantMessageType.MODEL.value, "is_final": True}],
        assistant_context=assistant,
        assistant_id_key="conv1",
        user_id="U123",
    )

    assert handler.last_posted_blocks[0] == {
        "type": "section",
        "text": {"type": "mrkdwn", "text": "<@U123>"},
    }


def test_send_agent_response_step_no_user_mention(mocker: MockerFixture):
    """
    Given:
        Step message type with a user_id provided.
    When:
        Sending agent response.
    Then:
        Does not insert a user mention block (only model types get mentions).
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[{"content": "Step 1", "response_type": AssistantMessageType.STEP.value, "is_final": False}],
        assistant_context=assistant,
        assistant_id_key="conv1",
        user_id="U123",
    )

    # Step types use prepare_merged_step_blocks, not prepare_message_blocks
    # The merged_steps block should not contain user mention
    for block in handler.last_posted_blocks:
        assert block.get("text", {}).get("text") != "<@U123>"


def test_group_messages_by_type_consecutive_steps_merged():
    """
    Given:
        Messages list with consecutive step-type messages.
    When:
        Grouping messages by type.
    Then:
        Consecutive step messages are grouped together.
    """
    messages = [
        {"content": "Step 1", "response_type": "step", "is_final": False},
        {"content": "Step 2", "response_type": "thought", "is_final": False},
        {"content": "Final answer", "response_type": "model", "is_final": True},
    ]

    groups = AssistantMessagingHandler._group_messages_by_type(messages)

    assert len(groups) == 2
    assert len(groups[0]) == 2  # step + thought merged
    assert groups[0][0]["response_type"] == "step"
    assert groups[0][1]["response_type"] == "thought"
    assert len(groups[1]) == 1  # model separate
    assert groups[1][0]["response_type"] == "model"


def test_group_messages_by_type_mixed_types_separate():
    """
    Given:
        Messages list with alternating step and non-step types.
    When:
        Grouping messages by type.
    Then:
        Non-step types are kept as individual groups.
    """
    messages = [
        {"content": "Step 1", "response_type": "step", "is_final": False},
        {"content": "Approval needed", "response_type": "approval", "is_final": False},
        {"content": "Step 2", "response_type": "step", "is_final": False},
    ]

    groups = AssistantMessagingHandler._group_messages_by_type(messages)

    assert len(groups) == 3
    assert len(groups[0]) == 1
    assert len(groups[1]) == 1
    assert len(groups[2]) == 1


def test_send_agent_response_merges_consecutive_steps(mocker: MockerFixture):
    """
    Given:
        Messages list with two consecutive step messages followed by a model message.
    When:
        Sending agent response.
    Then:
        Step messages are merged into one post, model is sent separately.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    handler.posted_calls = []
    original_post = handler.post_agent_response

    def tracking_post(*args, **kwargs):
        result = original_post(*args, **kwargs)
        handler.posted_calls.append({"blocks": args[2] if len(args) > 2 else kwargs.get("blocks", [])})
        return result

    handler.post_agent_response = tracking_post
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[
            {"content": "Retrieving cases", "response_type": "step", "is_final": False},
            {"content": "Processing data", "response_type": "step", "is_final": False},
            {"content": "Here are the results", "response_type": "model", "is_final": True, "message_id": "msg1"},
        ],
        assistant_context=assistant,
        assistant_id_key="conv1",
    )

    # Should have 2 posts: one merged step, one model
    assert len(handler.posted_calls) == 2


def test_send_agent_response_completed_derived_from_is_final(mocker: MockerFixture):
    """
    Given:
        Messages list where last message has is_final=True.
    When:
        Sending agent response.
    Then:
        Completed is derived from last message's is_final and lock is released.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "results")
    handler = MockMessagingHandler()
    assistant = {"conv1": {"status": "awaiting_backend_response"}}

    result = handler.send_agent_response(
        channel_id="channel123",
        thread_id="thread123",
        messages=[
            {"content": "Step 1", "response_type": "step", "is_final": False},
            {"content": "Done", "response_type": "model", "is_final": True},
        ],
        assistant_context=assistant,
        assistant_id_key="conv1",
    )

    assert "conv1" not in result


def test_backend_response_includes_error_code(mocker):
    """
    Given:
        Backend response with error_code.
    When:
        Handling backend response.
    Then:
        BackendResponse includes error_code field.
    """
    mocker.patch.object(demisto, "error")
    handler = MockMessagingHandler()
    
    response = {"error_code": 999, "error": "Unknown error"}
    result = handler.handle_backend_response(response, "test_operation")
    
    assert result.success is False
    assert result.error_code == 999
    assert result.error_type == BackendErrorType.UNKNOWN


@pytest.mark.asyncio
async def test_sensitive_action_approval_decision_indicator_before_feedback(mocker: MockerFixture):
    """
    Given:
    	A successful sensitive action approval with blocks containing content, actions, and feedback.
    When:
    	The approval action is handled.
    Then:
    	The decision indicator is inserted before the feedback block, not appended at the end.
    """
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "agentixCommands", return_value={"success": True})

    handler = MockMessagingHandler()
    assistant = {
        "conv1": {
            "user": "U123",
            "status": AssistantStatus.AWAITING_SENSITIVE_ACTION_APPROVAL.value,
            "last_updated": datetime.now(UTC).timestamp(),
        }
    }

    # Simulate original message blocks: content → actions (approval) → feedback
    message = {
        "ts": "msg_ts",
        "blocks": [
            {"type": "section", "text": {"type": "mrkdwn", "text": "Sensitive action details"}},
            {"type": "actions", "elements": [{"type": "button", "text": {"type": "plain_text", "text": "Proceed"}}]},
            {"type": "actions", "elements": [{"type": "button", "text": {"type": "plain_text", "text": "Good response"}}]},
        ],
    }

    await handler._handle_action_sensitive_action_approval(
        action_id=AssistantActionIds.APPROVAL_YES.value,
        user_id="U123",
        user_email="user@example.com",
        channel_id="C123",
        thread_id="T123",
        message=message,
        message_id="msg_ts",
        assistant=assistant,
        assistant_id_key="conv1",
        locked_user="U123",
    )

    # Verify the update_message was called with correct block order
    updated = handler.updated_messages[0]
    blocks = updated["blocks"]

    # After removing the approval actions block:
    # [content, decision_indicator, feedback_actions]
    assert len(blocks) == 3
    assert blocks[0]["type"] == "section"  # content
    assert blocks[1]["type"] == "context"  # decision indicator
    assert blocks[1]["elements"][0]["text"] == AssistantMessages.DECISION_APPROVED
    assert blocks[2]["type"] == "actions"  # feedback buttons (last)