chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.processes.dapr_runtime import (
|
||||
EventBufferActor,
|
||||
ExternalEventBufferActor,
|
||||
MessageBufferActor,
|
||||
ProcessActor,
|
||||
StepActor,
|
||||
)
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_actor_registration import (
|
||||
create_actor_factories,
|
||||
register_fastapi_dapr_actors,
|
||||
register_flask_dapr_actors,
|
||||
)
|
||||
|
||||
|
||||
class MockActor:
|
||||
"""Mock actor to record register_actor calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.registrations = []
|
||||
|
||||
async def register_actor(self, actor_type, actor_factory=None):
|
||||
# Record registration details
|
||||
self.registrations.append({"actor_type": actor_type, "actor_factory": actor_factory})
|
||||
|
||||
def register_actor_sync(self, actor_type, actor_factory=None):
|
||||
# Synchronous version for Flask
|
||||
self.registrations.append({"actor_type": actor_type, "actor_factory": actor_factory})
|
||||
|
||||
|
||||
class MockFlaskDaprActor:
|
||||
"""Mock Flask actor with synchronous register_actor method."""
|
||||
|
||||
def __init__(self):
|
||||
self.registrations = []
|
||||
|
||||
def register_actor(self, actor_type, actor_factory=None):
|
||||
self.registrations.append({"actor_type": actor_type, "actor_factory": actor_factory})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kernel() -> MagicMock:
|
||||
"""Provides a mock kernel object."""
|
||||
return MagicMock(name="Kernel")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_factories() -> dict:
|
||||
"""Provides a mock factories dictionary."""
|
||||
return {"mock": lambda: "mock_factory"}
|
||||
|
||||
|
||||
def test_create_actor_factories_returns_factories(mock_kernel, mock_factories):
|
||||
"""
|
||||
Test that create_actor_factories returns callable factory functions that construct actors
|
||||
with the provided kernel and factories.
|
||||
"""
|
||||
process_factory, step_factory = create_actor_factories(mock_kernel, mock_factories)
|
||||
|
||||
# Check that the returned factories are callable
|
||||
assert callable(process_factory)
|
||||
assert callable(step_factory)
|
||||
|
||||
# Create mock context and actor_id
|
||||
mock_ctx = MagicMock()
|
||||
mock_actor_id = MagicMock()
|
||||
mock_actor_id.id = "actor_1"
|
||||
|
||||
# Call the factories to create ProcessActor and StepActor objects
|
||||
process_actor = process_factory(mock_ctx, mock_actor_id)
|
||||
step_actor = step_factory(mock_ctx, mock_actor_id)
|
||||
|
||||
# Check that the actors have the kernel and factories set correctly
|
||||
assert hasattr(process_actor, "kernel")
|
||||
assert process_actor.kernel == mock_kernel
|
||||
assert hasattr(process_actor, "factories")
|
||||
assert process_actor.factories == mock_factories
|
||||
|
||||
assert hasattr(step_actor, "kernel")
|
||||
assert step_actor.kernel == mock_kernel
|
||||
assert hasattr(step_actor, "factories")
|
||||
assert step_actor.factories == mock_factories
|
||||
|
||||
|
||||
async def test_register_fastapi_dapr_actors(mock_kernel, mock_factories):
|
||||
"""
|
||||
Test that register_fastapi_dapr_actors registers all the required actors with appropriate
|
||||
factories for FastAPI.
|
||||
"""
|
||||
mock_actor = MockActor()
|
||||
|
||||
# Call the registration function
|
||||
await register_fastapi_dapr_actors(mock_actor, mock_kernel, mock_factories)
|
||||
|
||||
# There should be 5 registrations: ProcessActor, StepActor (with factories) and
|
||||
# three registrations without factories
|
||||
expected_actor_types = {ProcessActor, StepActor, EventBufferActor, MessageBufferActor, ExternalEventBufferActor}
|
||||
registered_actor_types = {reg["actor_type"] for reg in mock_actor.registrations}
|
||||
|
||||
assert expected_actor_types == registered_actor_types
|
||||
|
||||
# Verify that ProcessActor and StepActor registrations have an actor_factory
|
||||
for reg in mock_actor.registrations:
|
||||
if reg["actor_type"] in {ProcessActor, StepActor}:
|
||||
assert reg["actor_factory"] is not None
|
||||
else:
|
||||
assert reg.get("actor_factory") is None
|
||||
|
||||
|
||||
def test_register_flask_dapr_actors(mock_kernel, mock_factories):
|
||||
"""Test that register_flask_dapr_actors registers all the required actors with appropriate factories for Flask."""
|
||||
mock_actor = MockFlaskDaprActor()
|
||||
|
||||
# Call the synchronous registration function
|
||||
register_flask_dapr_actors(mock_actor, mock_kernel, mock_factories)
|
||||
|
||||
# There should be 5 registrations: ProcessActor, StepActor (with factories) and
|
||||
# three registrations without factories
|
||||
expected_actor_types = {ProcessActor, StepActor, EventBufferActor, MessageBufferActor, ExternalEventBufferActor}
|
||||
registered_actor_types = {reg["actor_type"] for reg in mock_actor.registrations}
|
||||
|
||||
assert expected_actor_types == registered_actor_types
|
||||
|
||||
# Check that ProcessActor and StepActor registrations have non-null actor_factory
|
||||
for reg in mock_actor.registrations:
|
||||
if reg["actor_type"] in {ProcessActor, StepActor}:
|
||||
assert reg["actor_factory"] is not None
|
||||
else:
|
||||
assert reg.get("actor_factory") is None
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_kernel_process import start
|
||||
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
|
||||
|
||||
|
||||
class FakeDaprKernelProcessContext:
|
||||
def __init__(self, process, max_supersteps: int | None = None):
|
||||
self.process = process
|
||||
self.start_with_event = AsyncMock()
|
||||
self.max_supersteps = max_supersteps
|
||||
|
||||
|
||||
async def test_start_with_valid_parameters():
|
||||
state = MagicMock(spec=KernelProcessState)
|
||||
state.name = "valid_state"
|
||||
state.id = "state_1"
|
||||
|
||||
mock_step = MagicMock(spec=KernelProcessStepInfo)
|
||||
process = KernelProcess(state=state, steps=[mock_step])
|
||||
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
initial_event = KernelProcessEvent(id="event_1", data="data_1")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.processes.dapr_runtime.dapr_kernel_process.DaprKernelProcessContext",
|
||||
new=FakeDaprKernelProcessContext,
|
||||
):
|
||||
result = await start(process=process, kernel=kernel, initial_event=initial_event, max_supersteps=10)
|
||||
|
||||
assert isinstance(result, FakeDaprKernelProcessContext)
|
||||
assert result.process == process
|
||||
assert result.max_supersteps == 10
|
||||
result.start_with_event.assert_called_once_with(initial_event)
|
||||
|
||||
|
||||
async def test_start_with_invalid_process():
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
initial_event = KernelProcessEvent(id="event_1", data="data_1")
|
||||
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="process cannot be None"):
|
||||
await start(process=None, kernel=kernel, initial_event=initial_event)
|
||||
|
||||
|
||||
async def test_start_with_invalid_initial_event():
|
||||
state = MagicMock(spec=KernelProcessState)
|
||||
type(state).name = PropertyMock(return_value="valid_state")
|
||||
mock_step = MagicMock(spec=KernelProcessStepInfo)
|
||||
process = KernelProcess(state=state, steps=[mock_step])
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="initial_event cannot be None"):
|
||||
await start(process=process, kernel=kernel, initial_event=None)
|
||||
|
||||
|
||||
async def test_start_with_initial_event_as_string():
|
||||
state = MagicMock(spec=KernelProcessState)
|
||||
type(state).name = PropertyMock(return_value="valid_state")
|
||||
mock_step = MagicMock(spec=KernelProcessStepInfo)
|
||||
process = KernelProcess(state=state, steps=[mock_step])
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
initial_event = "start_event"
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.processes.dapr_runtime.dapr_kernel_process.DaprKernelProcessContext", new=MagicMock()
|
||||
) as mock_process_context_class:
|
||||
mock_process_context_instance = mock_process_context_class.return_value
|
||||
mock_process_context_instance.start_with_event = AsyncMock()
|
||||
|
||||
result = await start(process=process, kernel=kernel, initial_event=initial_event, data="event_data")
|
||||
|
||||
assert result == mock_process_context_instance
|
||||
mock_process_context_instance.start_with_event.assert_called_once()
|
||||
args, _ = mock_process_context_instance.start_with_event.call_args
|
||||
actual_event = args[0]
|
||||
assert isinstance(actual_event, KernelProcessEvent)
|
||||
assert actual_event.id == "start_event"
|
||||
assert actual_event.data == "event_data"
|
||||
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_kernel_process_context import DaprKernelProcessContext
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_process_info import DaprProcessInfo
|
||||
from semantic_kernel.processes.dapr_runtime.interfaces.process_interface import ProcessInterface
|
||||
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
|
||||
|
||||
|
||||
class DummyInnerStepType(KernelProcessStep):
|
||||
"""A valid KernelProcessStep subclass for testing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def process_context():
|
||||
state = KernelProcessState(name="TestProcess", version="1.0", id=str(uuid.uuid4()))
|
||||
|
||||
step_state = KernelProcessStepState(name="TestStep", version="1.0", id="step1")
|
||||
|
||||
step = KernelProcessStepInfo(
|
||||
state=step_state,
|
||||
inner_step_type=DummyInnerStepType,
|
||||
output_edges={},
|
||||
)
|
||||
|
||||
steps = [step]
|
||||
process = KernelProcess(state=state, steps=steps)
|
||||
|
||||
with patch("dapr.actor.ActorProxy.create") as mock_actor_proxy_create:
|
||||
mock_dapr_process = AsyncMock(spec=ProcessInterface)
|
||||
mock_actor_proxy_create.return_value = mock_dapr_process
|
||||
|
||||
context = DaprKernelProcessContext(
|
||||
process=process,
|
||||
allowed_module_prefixes=("semantic_kernel.", DummyInnerStepType.__module__),
|
||||
)
|
||||
|
||||
yield context, mock_dapr_process
|
||||
|
||||
|
||||
async def test_start_with_event(process_context):
|
||||
context, mock_dapr_process = process_context
|
||||
|
||||
initial_event = KernelProcessEvent(id="event1", data="some data")
|
||||
|
||||
await context.start_with_event(initial_event)
|
||||
|
||||
dapr_process_info = DaprProcessInfo.from_kernel_process(context.process)
|
||||
expected_payload = {
|
||||
"process_info": dapr_process_info.model_dump_json(),
|
||||
"parent_process_id": None,
|
||||
"max_supersteps": context.max_supersteps,
|
||||
}
|
||||
mock_dapr_process.initialize_process.assert_awaited_once_with(expected_payload)
|
||||
|
||||
initial_event_json = initial_event.model_dump_json()
|
||||
mock_dapr_process.run_once.assert_awaited_once_with(initial_event_json)
|
||||
|
||||
|
||||
async def test_send_event(process_context):
|
||||
context, mock_dapr_process = process_context
|
||||
|
||||
event = KernelProcessEvent(id="event2", data="event data")
|
||||
|
||||
await context.send_event(event)
|
||||
|
||||
mock_dapr_process.send_message.assert_awaited_once_with(event)
|
||||
|
||||
|
||||
async def test_stop(process_context):
|
||||
context, mock_dapr_process = process_context
|
||||
|
||||
await context.stop()
|
||||
|
||||
mock_dapr_process.stop.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_get_state(process_context):
|
||||
context, mock_dapr_process = process_context
|
||||
|
||||
dapr_process_info = DaprProcessInfo.from_kernel_process(context.process)
|
||||
mock_dapr_process.get_process_info.return_value = dapr_process_info
|
||||
|
||||
result = await context.get_state()
|
||||
|
||||
mock_dapr_process.get_process_info.assert_awaited_once()
|
||||
|
||||
assert isinstance(result, KernelProcess)
|
||||
assert result.state.id == context.process.state.id
|
||||
assert result.state.name == context.process.state.name
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from dapr.actor.id import ActorId
|
||||
from dapr.actor.runtime._type_information import ActorTypeInformation
|
||||
from dapr.actor.runtime.context import ActorRuntimeContext
|
||||
from dapr.serializers import DefaultJSONSerializer
|
||||
|
||||
from semantic_kernel.processes.dapr_runtime.actors.event_buffer_actor import EventBufferActor
|
||||
|
||||
|
||||
def _run(coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def actor_context():
|
||||
actor_id = ActorId("test_actor")
|
||||
actor_type_info = ActorTypeInformation.create(EventBufferActor)
|
||||
|
||||
message_serializer = DefaultJSONSerializer()
|
||||
state_serializer = DefaultJSONSerializer()
|
||||
|
||||
mock_actor_client = AsyncMock()
|
||||
|
||||
runtime_context = ActorRuntimeContext(
|
||||
actor_type_info=actor_type_info,
|
||||
message_serializer=message_serializer,
|
||||
state_serializer=state_serializer,
|
||||
actor_client=mock_actor_client,
|
||||
)
|
||||
|
||||
actor = EventBufferActor(runtime_context, actor_id)
|
||||
|
||||
_run(actor._on_activate())
|
||||
return actor
|
||||
|
||||
|
||||
def test_enqueue(actor_context):
|
||||
_run(actor_context.enqueue('{"event": "test_event"}'))
|
||||
queue_list = list(actor_context.queue.queue)
|
||||
assert queue_list == ['{"event": "test_event"}']
|
||||
|
||||
|
||||
def test_dequeue_all(actor_context):
|
||||
_run(actor_context.enqueue('{"event": "event1"}'))
|
||||
_run(actor_context.enqueue('{"event": "event2"}'))
|
||||
messages = _run(actor_context.dequeue_all())
|
||||
assert messages == ['{"event": "event1"}', '{"event": "event2"}']
|
||||
assert actor_context.queue.empty()
|
||||
|
||||
|
||||
def test_on_activate(actor_context):
|
||||
new_actor = EventBufferActor(actor_context.runtime_ctx, actor_context.id)
|
||||
_run(new_actor._on_activate())
|
||||
assert new_actor.queue.empty()
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from dapr.actor.id import ActorId
|
||||
from dapr.actor.runtime._type_information import ActorTypeInformation
|
||||
from dapr.actor.runtime.context import ActorRuntimeContext
|
||||
from dapr.serializers import DefaultJSONSerializer
|
||||
|
||||
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
|
||||
from semantic_kernel.processes.dapr_runtime.actors.external_event_buffer_actor import ExternalEventBufferActor
|
||||
|
||||
|
||||
def _run(coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def actor_context():
|
||||
actor_id = ActorId("test_actor")
|
||||
actor_type_info = ActorTypeInformation.create(ExternalEventBufferActor)
|
||||
|
||||
message_serializer = DefaultJSONSerializer()
|
||||
state_serializer = DefaultJSONSerializer()
|
||||
|
||||
mock_actor_client = AsyncMock()
|
||||
|
||||
mock_state_manager = AsyncMock()
|
||||
mock_state_manager.try_get_state.return_value = (True, json.dumps([]))
|
||||
mock_state_manager.try_add_state = AsyncMock()
|
||||
mock_state_manager.save_state = AsyncMock()
|
||||
|
||||
runtime_context = ActorRuntimeContext(
|
||||
actor_type_info=actor_type_info,
|
||||
message_serializer=message_serializer,
|
||||
state_serializer=state_serializer,
|
||||
actor_client=mock_actor_client,
|
||||
)
|
||||
|
||||
actor = ExternalEventBufferActor(runtime_context, actor_id)
|
||||
actor._state_manager = mock_state_manager
|
||||
|
||||
_run(actor._on_activate())
|
||||
return actor
|
||||
|
||||
|
||||
def test_enqueue(actor_context):
|
||||
_run(actor_context.enqueue('{"event": "test_event"}'))
|
||||
queue_list = list(actor_context.queue.queue)
|
||||
assert queue_list == ['{"event": "test_event"}']
|
||||
|
||||
actor_context._state_manager.try_add_state.assert_called_with(
|
||||
ActorStateKeys.ExternalEventQueueState.value, json.dumps(['{"event": "test_event"}'])
|
||||
)
|
||||
actor_context._state_manager.save_state.assert_called_once()
|
||||
|
||||
|
||||
def test_dequeue_all(actor_context):
|
||||
_run(actor_context.enqueue('{"event": "event1"}'))
|
||||
_run(actor_context.enqueue('{"event": "event2"}'))
|
||||
messages = _run(actor_context.dequeue_all())
|
||||
assert messages == ['{"event": "event1"}', '{"event": "event2"}']
|
||||
assert actor_context.queue.empty()
|
||||
|
||||
actor_context._state_manager.try_add_state.assert_called_with(
|
||||
ActorStateKeys.ExternalEventQueueState.value, json.dumps([])
|
||||
)
|
||||
actor_context._state_manager.save_state.assert_called()
|
||||
|
||||
|
||||
def test_on_activate_with_existing_state(actor_context):
|
||||
actor_context._state_manager.try_get_state.return_value = (
|
||||
True,
|
||||
json.dumps(['{"event": "event1"}', '{"event": "event2"}']),
|
||||
)
|
||||
|
||||
new_actor = ExternalEventBufferActor(actor_context.runtime_ctx, actor_context.id)
|
||||
new_actor._state_manager = actor_context._state_manager
|
||||
_run(new_actor._on_activate())
|
||||
|
||||
queue_list = list(new_actor.queue.queue)
|
||||
assert queue_list == ['{"event": "event1"}', '{"event": "event2"}']
|
||||
|
||||
|
||||
def test_on_activate_with_no_existing_state(actor_context):
|
||||
actor_context._state_manager.try_get_state.return_value = (False, None)
|
||||
|
||||
new_actor = ExternalEventBufferActor(actor_context.runtime_ctx, actor_context.id)
|
||||
new_actor._state_manager = actor_context._state_manager
|
||||
_run(new_actor._on_activate())
|
||||
|
||||
assert new_actor.queue.empty()
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from dapr.actor.id import ActorId
|
||||
from dapr.actor.runtime._type_information import ActorTypeInformation
|
||||
from dapr.actor.runtime.context import ActorRuntimeContext
|
||||
from dapr.serializers import DefaultJSONSerializer
|
||||
|
||||
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
|
||||
from semantic_kernel.processes.dapr_runtime.actors.message_buffer_actor import MessageBufferActor
|
||||
|
||||
|
||||
def _run(coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def actor_context():
|
||||
actor_id = ActorId("test_actor")
|
||||
actor_type_info = ActorTypeInformation.create(MessageBufferActor)
|
||||
|
||||
message_serializer = DefaultJSONSerializer()
|
||||
state_serializer = DefaultJSONSerializer()
|
||||
|
||||
mock_actor_client = AsyncMock()
|
||||
|
||||
mock_state_manager = AsyncMock()
|
||||
mock_state_manager.try_get_state.return_value = (True, [])
|
||||
mock_state_manager.try_add_state = AsyncMock()
|
||||
mock_state_manager.save_state = AsyncMock()
|
||||
|
||||
runtime_context = ActorRuntimeContext(
|
||||
actor_type_info=actor_type_info,
|
||||
message_serializer=message_serializer,
|
||||
state_serializer=state_serializer,
|
||||
actor_client=mock_actor_client,
|
||||
)
|
||||
|
||||
actor = MessageBufferActor(runtime_context, actor_id)
|
||||
actor._state_manager = mock_state_manager
|
||||
|
||||
_run(actor._on_activate())
|
||||
return actor
|
||||
|
||||
|
||||
def test_enqueue(actor_context):
|
||||
_run(actor_context.enqueue('{"event": "test_event"}'))
|
||||
queue_list = list(actor_context.queue.queue)
|
||||
assert queue_list == ['{"event": "test_event"}']
|
||||
|
||||
actor_context._state_manager.try_add_state.assert_called_with(
|
||||
ActorStateKeys.MessageQueueState.value, ['{"event": "test_event"}']
|
||||
)
|
||||
actor_context._state_manager.save_state.assert_called_once()
|
||||
|
||||
|
||||
def test_dequeue_all(actor_context):
|
||||
_run(actor_context.enqueue('{"event": "event1"}'))
|
||||
_run(actor_context.enqueue('{"event": "event2"}'))
|
||||
|
||||
actor_context._state_manager.reset_mock()
|
||||
|
||||
messages = _run(actor_context.dequeue_all())
|
||||
assert messages == ['{"event": "event1"}', '{"event": "event2"}']
|
||||
assert actor_context.queue.empty()
|
||||
|
||||
actor_context._state_manager.try_add_state.assert_called_with(ActorStateKeys.MessageQueueState.value, "[]")
|
||||
actor_context._state_manager.save_state.assert_called_once()
|
||||
|
||||
|
||||
def test_on_activate_with_existing_state(actor_context):
|
||||
actor_context._state_manager.try_get_state.return_value = (True, ['{"event": "event1"}', '{"event": "event2"}'])
|
||||
|
||||
new_actor = MessageBufferActor(actor_context.runtime_ctx, actor_context.id)
|
||||
new_actor._state_manager = actor_context._state_manager
|
||||
_run(new_actor._on_activate())
|
||||
|
||||
queue_list = list(new_actor.queue.queue)
|
||||
assert queue_list == ['{"event": "event1"}', '{"event": "event2"}']
|
||||
|
||||
|
||||
def test_on_activate_with_no_existing_state(actor_context):
|
||||
actor_context._state_manager.try_get_state.return_value = (False, None)
|
||||
|
||||
new_actor = MessageBufferActor(actor_context.runtime_ctx, actor_context.id)
|
||||
new_actor._state_manager = actor_context._state_manager
|
||||
_run(new_actor._on_activate())
|
||||
|
||||
assert new_actor.queue.empty()
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from queue import Queue
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from dapr.actor.id import ActorId
|
||||
from dapr.actor.runtime._type_information import ActorTypeInformation
|
||||
from dapr.actor.runtime.context import ActorRuntimeContext
|
||||
|
||||
from semantic_kernel.exceptions.kernel_exceptions import KernelException
|
||||
from semantic_kernel.exceptions.process_exceptions import ProcessEventUndefinedException
|
||||
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
|
||||
from semantic_kernel.processes.dapr_runtime.actors.process_actor import ProcessActor
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_process_info import DaprProcessInfo
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_step_info import DaprStepInfo
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def actor_context():
|
||||
actor_id = ActorId("test_actor")
|
||||
actor_type_info = ActorTypeInformation.create(ProcessActor)
|
||||
runtime_context = ActorRuntimeContext(
|
||||
actor_type_info=actor_type_info,
|
||||
message_serializer=MagicMock(),
|
||||
state_serializer=MagicMock(),
|
||||
actor_client=MagicMock(),
|
||||
)
|
||||
kernel_mock = MagicMock()
|
||||
actor = ProcessActor(runtime_context, actor_id, kernel=kernel_mock, factories={})
|
||||
|
||||
actor._state_manager = AsyncMock()
|
||||
actor._state_manager.try_add_state = AsyncMock(return_value=True)
|
||||
actor._state_manager.try_get_state = AsyncMock(return_value=(True, {}))
|
||||
actor._state_manager.save_state = AsyncMock()
|
||||
|
||||
asyncio.run(actor._on_activate())
|
||||
return actor
|
||||
|
||||
|
||||
def clean_structure(data):
|
||||
"""Recursively remove null values and empty dictionaries for direct comparison."""
|
||||
if isinstance(data, dict):
|
||||
return {k: clean_structure(v) for k, v in data.items() if v not in [None, {}]}
|
||||
if isinstance(data, list):
|
||||
return [clean_structure(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
async def test_initialize_process(actor_context):
|
||||
input_data = {
|
||||
"process_info": {
|
||||
"type": "DaprProcessInfo",
|
||||
"state": {"type": "KernelProcessState", "name": "Test Process", "version": "1.0", "id": "proc_123"},
|
||||
"steps": [
|
||||
{
|
||||
"type": "DaprStepInfo",
|
||||
"inner_step_python_type": "SomeStepType",
|
||||
"state": {"type": "KernelProcessState", "name": "Step1", "version": "1.0", "id": "step_123"},
|
||||
}
|
||||
],
|
||||
"inner_step_python_type": "SomeProcessType",
|
||||
"edges": {},
|
||||
},
|
||||
"parent_process_id": "parent_123",
|
||||
}
|
||||
|
||||
expected_process_info = clean_structure(input_data["process_info"])
|
||||
|
||||
dapr_process_info_instance = DaprProcessInfo(
|
||||
inner_step_python_type="SomeProcessType",
|
||||
state=KernelProcessState(name="Test Process", version="1.0", id="proc_123"),
|
||||
edges={},
|
||||
steps=[
|
||||
DaprStepInfo(
|
||||
inner_step_python_type="SomeStepType",
|
||||
state=KernelProcessState(name="Step1", version="1.0", id="step_123"),
|
||||
edges={},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with (
|
||||
patch.multiple(
|
||||
actor_context,
|
||||
_initialize_process_actor=AsyncMock(),
|
||||
_state_manager=actor_context._state_manager,
|
||||
),
|
||||
patch.object(actor_context._state_manager, "save_state", new=AsyncMock()) as mock_save_state,
|
||||
):
|
||||
await actor_context.initialize_process(input_data)
|
||||
|
||||
actual_calls = actor_context._state_manager.try_add_state.call_args_list
|
||||
actual_process_info_call = next(
|
||||
(call for call in actual_calls if call[0][0] == ActorStateKeys.ProcessInfoState.value), None
|
||||
)
|
||||
|
||||
assert actual_process_info_call is not None, "ProcessInfoState call was not found."
|
||||
actual_process_info = clean_structure(actual_process_info_call[0][1])
|
||||
|
||||
assert actual_process_info == expected_process_info, (
|
||||
f"Expected: {json.dumps(expected_process_info)}, but got: {json.dumps(actual_process_info)}"
|
||||
)
|
||||
|
||||
mock_save_state.assert_called_once()
|
||||
|
||||
actor_context._initialize_process_actor.assert_called_once_with(dapr_process_info_instance, "parent_123")
|
||||
|
||||
|
||||
async def test_start_process(actor_context):
|
||||
actor_context.initialize_task = True
|
||||
|
||||
with patch.object(actor_context, "internal_execute", new=AsyncMock()) as mock_internal_execute:
|
||||
await actor_context.start(keep_alive=False)
|
||||
|
||||
assert actor_context.process_task is not None
|
||||
mock_internal_execute.assert_called_once()
|
||||
assert not actor_context.process_task.done()
|
||||
|
||||
|
||||
def test_run_once(actor_context):
|
||||
actor_context.initialize_task = True
|
||||
process_event = '{"event": "test_event"}'
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.process_actor.ActorProxy.create", return_value=AsyncMock()
|
||||
) as mock_proxy:
|
||||
asyncio.run(actor_context.run_once(process_event))
|
||||
|
||||
external_event_queue = mock_proxy.return_value
|
||||
external_event_queue.enqueue.assert_called_once_with(process_event)
|
||||
|
||||
assert actor_context.process_task is not None
|
||||
|
||||
|
||||
async def test_stop(actor_context):
|
||||
actor_context.initialize_task = True
|
||||
actor_context.process_task = asyncio.create_task(asyncio.sleep(1))
|
||||
|
||||
await actor_context.stop()
|
||||
|
||||
assert actor_context.process_task.done()
|
||||
|
||||
|
||||
def test_get_process_info(actor_context):
|
||||
with patch.object(actor_context, "to_dapr_process_info", return_value=MagicMock()) as mock_to_dapr_process_info:
|
||||
process_info = asyncio.run(actor_context.get_process_info())
|
||||
mock_to_dapr_process_info.assert_called_once()
|
||||
assert process_info is mock_to_dapr_process_info.return_value
|
||||
|
||||
|
||||
def test_handle_message(actor_context):
|
||||
message_mock = MagicMock()
|
||||
message_mock.target_event_id = "test_event"
|
||||
actor_context.output_edges = {"test_event": [MagicMock()]}
|
||||
|
||||
with patch.object(actor_context, "run_once", new=AsyncMock()) as mock_run_once:
|
||||
asyncio.run(actor_context.handle_message(message_mock))
|
||||
|
||||
mock_run_once.assert_called_once()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def actor() -> ProcessActor:
|
||||
"""Create a fresh ProcessActor with mocked dependencies."""
|
||||
# Arrange: Create a dummy runtime context and ProcessActor instance
|
||||
actor_id = ActorId("test_actor")
|
||||
runtime_context = MagicMock()
|
||||
kernel = MagicMock()
|
||||
actor_obj = ProcessActor(runtime_context, actor_id, kernel=kernel, factories={})
|
||||
# Mock internal state manager
|
||||
actor_obj._state_manager = AsyncMock()
|
||||
actor_obj._state_manager.try_get_state = AsyncMock(return_value=(False, None))
|
||||
actor_obj._state_manager.try_add_state = AsyncMock()
|
||||
actor_obj._state_manager.save_state = AsyncMock()
|
||||
return actor_obj
|
||||
|
||||
|
||||
def test_name_uninitialized(actor: ProcessActor):
|
||||
"""Test that accessing name before initialization raises KernelException."""
|
||||
with pytest.raises(KernelException) as exc_info:
|
||||
_ = actor.name
|
||||
assert "must be initialized before accessing the name" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_not_initialized(actor: ProcessActor):
|
||||
"""Test that start() without initialization raises ValueError."""
|
||||
actor.initialize_task = False
|
||||
with pytest.raises(ValueError):
|
||||
await actor.start()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_once_none_event(actor: ProcessActor):
|
||||
"""Test that run_once(None) raises ProcessEventUndefinedException."""
|
||||
actor.initialize_task = True
|
||||
with pytest.raises(ProcessEventUndefinedException):
|
||||
await actor.run_once(None) # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_none_event(actor: ProcessActor):
|
||||
"""Test that send_message(None) raises ProcessEventUndefinedException."""
|
||||
with pytest.raises(ProcessEventUndefinedException):
|
||||
await actor.send_message(None) # type: ignore
|
||||
|
||||
|
||||
def test_send_message_success(actor: ProcessActor):
|
||||
"""Test that send_message enqueues the event into external_event_queue."""
|
||||
event = KernelProcessEvent(id="e1", data="d1")
|
||||
asyncio.run(actor.send_message(event))
|
||||
assert isinstance(actor.external_event_queue, Queue)
|
||||
assert not actor.external_event_queue.empty()
|
||||
queued = actor.external_event_queue.get()
|
||||
assert queued is event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_dapr_process_info_uninitialized(actor: ProcessActor):
|
||||
"""Test to_dapr_process_info raises ValueError if process is None."""
|
||||
actor.process = None
|
||||
with pytest.raises(ValueError) as exc:
|
||||
await actor.to_dapr_process_info()
|
||||
assert "must be initialized before converting" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_dapr_process_info_inner_step_type_none(actor: ProcessActor):
|
||||
"""Test to_dapr_process_info raises ValueError if inner_step_python_type is None."""
|
||||
actor.process = MagicMock()
|
||||
# Simulate a process with missing inner_step_python_type
|
||||
actor.process.inner_step_python_type = None
|
||||
actor.process.state = MagicMock(name="Proc", id="pid")
|
||||
actor.steps = []
|
||||
with pytest.raises(ValueError) as exc:
|
||||
await actor.to_dapr_process_info()
|
||||
assert "inner step type must be defined" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_dapr_process_info_success(actor: ProcessActor):
|
||||
"""Test to_dapr_process_info returns correct dict for initialized process with no steps."""
|
||||
proc_state = KernelProcessState(name="Proc", version="1.0", id="test_actor")
|
||||
dapr_proc = DaprProcessInfo(
|
||||
inner_step_python_type="Type1",
|
||||
state=proc_state,
|
||||
edges={},
|
||||
steps=[],
|
||||
)
|
||||
actor.process = dapr_proc
|
||||
actor.steps = []
|
||||
result = await actor.to_dapr_process_info()
|
||||
assert result == dapr_proc.model_dump()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_no_task(actor: ProcessActor):
|
||||
"""Test stop() returns normally when no process_task is running."""
|
||||
actor.process_task = None
|
||||
await actor.stop()
|
||||
|
||||
|
||||
def test_name_after_manual_set(actor: ProcessActor):
|
||||
"""Test that name property returns the correct name after manual initialization."""
|
||||
actor.process = MagicMock()
|
||||
actor.process.state = MagicMock()
|
||||
actor.process.state.name = "MyProcess"
|
||||
actor.process.state.id = "id123"
|
||||
assert actor.name == "MyProcess"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_outgoing_public_events_no_parent(actor: ProcessActor):
|
||||
"""Test send_outgoing_public_events does nothing if parent_process_id is None."""
|
||||
actor.parent_process_id = None
|
||||
with patch("semantic_kernel.processes.dapr_runtime.actors.process_actor.ActorProxy.create") as mock_proxy:
|
||||
await actor.send_outgoing_public_events()
|
||||
mock_proxy.assert_not_called()
|
||||
@@ -0,0 +1,465 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from dapr.actor import ActorId, ActorProxy
|
||||
|
||||
from semantic_kernel.exceptions.kernel_exceptions import KernelException
|
||||
from semantic_kernel.exceptions.process_exceptions import (
|
||||
ProcessFunctionNotFoundException,
|
||||
)
|
||||
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
|
||||
from semantic_kernel.processes.dapr_runtime.actors.event_buffer_actor import EventBufferActor
|
||||
from semantic_kernel.processes.dapr_runtime.actors.step_actor import StepActor
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_step_info import DaprStepInfo
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_event import (
|
||||
KernelProcessEvent,
|
||||
KernelProcessEventVisibility,
|
||||
)
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_function_target import KernelProcessFunctionTarget
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
|
||||
from semantic_kernel.processes.process_event import ProcessEvent
|
||||
from semantic_kernel.processes.process_message import ProcessMessage
|
||||
|
||||
|
||||
class FakeStep:
|
||||
async def activate(self, state):
|
||||
self.activated_state = state
|
||||
|
||||
|
||||
class FakeState:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def actor_context():
|
||||
ctx = MagicMock()
|
||||
actor_id = ActorId("test_actor")
|
||||
kernel = MagicMock()
|
||||
return StepActor(ctx, actor_id, kernel, factories={})
|
||||
|
||||
|
||||
async def test_initialize_step(actor_context):
|
||||
input_data = json.dumps({
|
||||
"step_info": {
|
||||
"state": {"name": "TestStep", "version": "1.0", "id": "step_1"},
|
||||
"inner_step_python_type": "SomeStepType",
|
||||
"edges": {},
|
||||
},
|
||||
"parent_process_id": "parent_1",
|
||||
})
|
||||
|
||||
with (
|
||||
patch.object(actor_context._state_manager, "try_add_state", new=AsyncMock()) as mock_try_add_state,
|
||||
patch.object(actor_context._state_manager, "save_state", new=AsyncMock()) as mock_save_state,
|
||||
):
|
||||
await actor_context.initialize_step(input_data)
|
||||
|
||||
assert actor_context.step_info is not None
|
||||
mock_try_add_state.assert_any_call("DaprStepInfo", actor_context.step_info.model_dump_json())
|
||||
mock_save_state.assert_called_once()
|
||||
|
||||
|
||||
async def test_prepare_incoming_messages(actor_context):
|
||||
message = ProcessMessage(
|
||||
source_id="source_1",
|
||||
destination_id="dest_1",
|
||||
function_name="test_function",
|
||||
values={"param1": "value1"},
|
||||
)
|
||||
mock_message_json = json.dumps(message.model_dump())
|
||||
|
||||
expected_state_key = "incomingMessagesState"
|
||||
|
||||
with (
|
||||
patch("dapr.actor.ActorProxy.create", new=MagicMock()) as mock_actor_proxy,
|
||||
patch.object(actor_context._state_manager, "try_add_state", new=AsyncMock()) as mock_try_add_state,
|
||||
patch.object(actor_context._state_manager, "save_state", new=AsyncMock()) as mock_save_state,
|
||||
):
|
||||
mock_queue = AsyncMock()
|
||||
mock_queue.dequeue_all.return_value = [mock_message_json]
|
||||
mock_actor_proxy.return_value = mock_queue
|
||||
|
||||
incoming_message_count = await actor_context.prepare_incoming_messages()
|
||||
|
||||
assert incoming_message_count == 1
|
||||
assert actor_context.incoming_messages.qsize() == 1
|
||||
mock_try_add_state.assert_called_with(expected_state_key, [mock_message_json])
|
||||
mock_save_state.assert_called_once()
|
||||
|
||||
|
||||
async def test_process_incoming_messages(actor_context):
|
||||
actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="Test Step", version="1.0", id="step_123"),
|
||||
inner_step_python_type="SomeStepType",
|
||||
edges={},
|
||||
)
|
||||
|
||||
message = ProcessMessage(
|
||||
source_id="source_1",
|
||||
destination_id="dest_1",
|
||||
function_name="test_function",
|
||||
values={"param1": "value1"},
|
||||
)
|
||||
actor_context.incoming_messages.put(message)
|
||||
|
||||
with (
|
||||
patch.object(actor_context, "handle_message", new=AsyncMock()) as mock_handle_message,
|
||||
patch.object(actor_context._state_manager, "save_state", new=AsyncMock()) as mock_save_state,
|
||||
patch.object(
|
||||
actor_context._state_manager, "try_add_state", new=AsyncMock(return_value=True)
|
||||
) as mock_try_add_state,
|
||||
):
|
||||
await actor_context.process_incoming_messages()
|
||||
mock_handle_message.assert_called_once_with(message)
|
||||
mock_save_state.assert_called_once()
|
||||
expected_messages = []
|
||||
expected_messages = [json.dumps(msg.model_dump()) for msg in list(actor_context.incoming_messages.queue)]
|
||||
mock_try_add_state.assert_any_call("incomingMessagesState", expected_messages)
|
||||
|
||||
|
||||
async def test_activate_step_with_factory_creates_state(actor_context):
|
||||
fake_step_instance = FakeStep()
|
||||
fake_step_instance.activate = AsyncMock(side_effect=fake_step_instance.activate)
|
||||
|
||||
fake_plugin = MagicMock()
|
||||
fake_plugin.functions = {"test_function": lambda x: x}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.step_actor.get_generic_state_type",
|
||||
return_value=FakeState,
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.step_actor.get_fully_qualified_name",
|
||||
return_value="FakeStateFullyQualified",
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.step_actor.find_input_channels",
|
||||
return_value={"channel": {"input": "value"}},
|
||||
),
|
||||
):
|
||||
actor_context.factories = {"FakeStep": lambda: fake_step_instance}
|
||||
actor_context.inner_step_type = "FakeStep"
|
||||
actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="default_name", version="2.0", id="step_123"),
|
||||
inner_step_python_type="FakeStep",
|
||||
edges={},
|
||||
)
|
||||
actor_context.kernel.add_plugin = MagicMock(return_value=fake_plugin)
|
||||
actor_context._state_manager.try_add_state = AsyncMock()
|
||||
actor_context._state_manager.save_state = AsyncMock()
|
||||
|
||||
await actor_context.activate_step()
|
||||
|
||||
actor_context.kernel.add_plugin.assert_called_once_with(fake_step_instance, "default_name")
|
||||
assert actor_context.functions == fake_plugin.functions
|
||||
assert actor_context.initial_inputs == {"channel": {"input": "value"}}
|
||||
assert actor_context.inputs == {"channel": {"input": "value"}}
|
||||
assert actor_context.step_state is not None
|
||||
assert actor_context.step_state.version == "2.0"
|
||||
assert isinstance(actor_context.step_state.state, FakeState)
|
||||
fake_step_instance.activate.assert_awaited_once_with(actor_context.step_state)
|
||||
|
||||
|
||||
async def test_activate_step_with_factory_uses_existing_state(actor_context):
|
||||
fake_step_instance = FakeStep()
|
||||
fake_step_instance.activate = AsyncMock(side_effect=fake_step_instance.activate)
|
||||
|
||||
fake_plugin = MagicMock()
|
||||
fake_plugin.functions = {"test_function": lambda x: x}
|
||||
|
||||
pre_existing_state = KernelProcessStepState(name="ExistingState", version="v1.0", id="ExistingState", state=None)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
KernelProcessStepState,
|
||||
"model_dump",
|
||||
return_value={"name": "ExistingState", "id": "ExistingState", "state": None},
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.step_actor.get_generic_state_type",
|
||||
return_value=FakeState,
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.step_actor.get_fully_qualified_name",
|
||||
return_value="FakeStateFullyQualified",
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.processes.dapr_runtime.actors.step_actor.find_input_channels",
|
||||
return_value={"channel": {"input": "value"}},
|
||||
),
|
||||
):
|
||||
actor_context.factories = {"FakeStep": lambda: fake_step_instance}
|
||||
actor_context.inner_step_type = "FakeStep"
|
||||
actor_context.step_info = DaprStepInfo(state=pre_existing_state, inner_step_python_type="FakeStep", edges={})
|
||||
actor_context.kernel.add_plugin = MagicMock(return_value=fake_plugin)
|
||||
actor_context._state_manager.try_add_state = AsyncMock()
|
||||
actor_context._state_manager.save_state = AsyncMock()
|
||||
|
||||
await actor_context.activate_step()
|
||||
|
||||
actor_context.kernel.add_plugin.assert_called_once_with(fake_step_instance, pre_existing_state.name)
|
||||
assert actor_context.functions == fake_plugin.functions
|
||||
assert actor_context.initial_inputs == {"channel": {"input": "value"}}
|
||||
assert actor_context.inputs == {"channel": {"input": "value"}}
|
||||
assert actor_context.step_state is not None
|
||||
assert actor_context.step_state.version == "v1.0"
|
||||
actor_context._state_manager.try_add_state.assert_any_await(
|
||||
ActorStateKeys.StepStateType.value, "FakeStateFullyQualified"
|
||||
)
|
||||
actor_context._state_manager.try_add_state.assert_any_await(
|
||||
ActorStateKeys.StepStateJson.value, json.dumps(pre_existing_state.model_dump())
|
||||
)
|
||||
actor_context._state_manager.save_state.assert_awaited_once()
|
||||
assert isinstance(actor_context.step_state.state, FakeState)
|
||||
fake_step_instance.activate.assert_awaited_once_with(actor_context.step_state)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_actor_context():
|
||||
"""Provides a fresh StepActor instance with mocked dependencies."""
|
||||
ctx = MagicMock()
|
||||
actor_id = ActorId("test_actor")
|
||||
kernel = MagicMock()
|
||||
actor = StepActor(ctx, actor_id, kernel, factories={})
|
||||
actor._state_manager = MagicMock()
|
||||
return actor
|
||||
|
||||
|
||||
def test_name_property_before_init_raises(mock_actor_context):
|
||||
"""Accessing .name before initialization raises KernelException"""
|
||||
with pytest.raises(KernelException):
|
||||
_ = mock_actor_context.name
|
||||
|
||||
|
||||
async def test_name_property_after_init_returns_name(mock_actor_context):
|
||||
"""After setting step_info, name returns the state's name"""
|
||||
step_state = KernelProcessStepState(name="MyStep", version="v1", id="id1")
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=step_state,
|
||||
inner_step_python_type="Any",
|
||||
edges={},
|
||||
)
|
||||
# should return MyStep
|
||||
assert mock_actor_context.name == "MyStep"
|
||||
|
||||
|
||||
def test_get_edge_for_event_empty(mock_actor_context):
|
||||
"""get_edge_for_event returns empty when no edges set"""
|
||||
mock_actor_context.output_edges = {}
|
||||
assert mock_actor_context.get_edge_for_event("event") == []
|
||||
|
||||
|
||||
def test_get_edge_for_event_present(mock_actor_context):
|
||||
"""get_edge_for_event returns correct list"""
|
||||
edge = KernelProcessEdge(
|
||||
source_step_id="s1",
|
||||
output_target=KernelProcessFunctionTarget(
|
||||
step_id="t1", function_name="f", parameter_name=None, target_event_id="e1"
|
||||
),
|
||||
)
|
||||
|
||||
mock_actor_context.output_edges = {"e1": [edge]}
|
||||
assert mock_actor_context.get_edge_for_event("e1") == [edge]
|
||||
|
||||
|
||||
def test_scoped_event_none_raises(mock_actor_context):
|
||||
"""scoped_event with None raises ValueError"""
|
||||
# Use cast to satisfy type checker
|
||||
with pytest.raises(ValueError):
|
||||
mock_actor_context.scoped_event(cast(ProcessEvent, None))
|
||||
|
||||
|
||||
def test_scoped_event_sets_namespace(mock_actor_context):
|
||||
"""scoped_event updates event namespace correctly"""
|
||||
# prepare name and id
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="StepName", version="v1", id="id"),
|
||||
inner_step_python_type="Any",
|
||||
edges={},
|
||||
)
|
||||
# create event
|
||||
evt = ProcessEvent(inner_event=KernelProcessEvent(id="e", data="d"), namespace=None)
|
||||
result = mock_actor_context.scoped_event(evt)
|
||||
assert result.namespace == f"StepName_{mock_actor_context.id.id}"
|
||||
|
||||
|
||||
async def test_invoke_function_calls_kernel_invoke(mock_actor_context):
|
||||
"""invoke_function delegates to kernel.invoke with correct args"""
|
||||
fake_fn = MagicMock(name="f")
|
||||
fake_kernel = MagicMock()
|
||||
fake_kernel.invoke = AsyncMock(return_value="result")
|
||||
res = await mock_actor_context.invoke_function(fake_fn, fake_kernel, {"a": 1})
|
||||
fake_kernel.invoke.assert_awaited_once_with(fake_fn, a=1)
|
||||
assert res == "result"
|
||||
|
||||
|
||||
async def test_emit_event_without_namespace_raises(mock_actor_context):
|
||||
"""emit_event without setting event_namespace raises ValueError"""
|
||||
mock_actor_context.event_namespace = None
|
||||
with pytest.raises(ValueError):
|
||||
await mock_actor_context.emit_event(KernelProcessEvent(id="e", data="d"))
|
||||
|
||||
|
||||
async def test_emit_process_event_public_and_edge(mock_actor_context):
|
||||
"""emit_process_event enqueues event to parent buffer and sends messages to targets"""
|
||||
# setup event as public and parent process id
|
||||
mock_actor_context.parent_process_id = "parent1"
|
||||
# fake ActorProxy.create to return AsyncMock for enqueue
|
||||
mock_parent = AsyncMock(spec=EventBufferActor)
|
||||
mock_child = AsyncMock(spec=EventBufferActor)
|
||||
|
||||
def fake_create(actor_type, actor_id, actor_interface):
|
||||
# first call is for EventBufferInterface, second for MessageBufferInterface
|
||||
if actor_interface.__name__ == "EventBufferInterface":
|
||||
return mock_parent
|
||||
return mock_child
|
||||
|
||||
with patch.object(ActorProxy, "create", side_effect=fake_create):
|
||||
# create an edge for event
|
||||
target = KernelProcessFunctionTarget(
|
||||
step_id="child1", function_name="func", parameter_name="p", target_event_id="evt"
|
||||
)
|
||||
edge = KernelProcessEdge(source_step_id="src", output_target=target)
|
||||
|
||||
mock_actor_context.output_edges = {"evt": [edge]}
|
||||
mock_actor_context.get_edge_for_event = MagicMock(return_value=[edge])
|
||||
|
||||
# call
|
||||
evt = ProcessEvent(
|
||||
inner_event=KernelProcessEvent(id="evt", data="data", visibility=KernelProcessEventVisibility.Public),
|
||||
namespace="ns",
|
||||
)
|
||||
await mock_actor_context.emit_process_event(evt)
|
||||
# verify enqueue on parent and child
|
||||
mock_parent.enqueue.assert_awaited_once()
|
||||
mock_child.enqueue.assert_awaited_once()
|
||||
|
||||
|
||||
@patch.object(StepActor, "activate_step", AsyncMock())
|
||||
async def test_to_dapr_step_info_errors(mock_actor_context):
|
||||
"""to_dapr_step_info raises if uninitialized"""
|
||||
# case: step_info not set
|
||||
mock_actor_context.step_activated = False
|
||||
mock_actor_context.step_info = None
|
||||
with pytest.raises(ValueError):
|
||||
await mock_actor_context.to_dapr_step_info()
|
||||
|
||||
# case: inner_step_type not set
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="S", version="v", id="id"),
|
||||
inner_step_python_type="Type",
|
||||
edges={},
|
||||
)
|
||||
mock_actor_context.inner_step_type = None
|
||||
mock_actor_context.step_activated = True
|
||||
with pytest.raises(ValueError):
|
||||
await mock_actor_context.to_dapr_step_info()
|
||||
|
||||
|
||||
async def test_to_dapr_step_info_success(mock_actor_context):
|
||||
"""to_dapr_step_info returns correct model_dump"""
|
||||
# setup valid step_info and inner_step_type
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="N", version="v", id="id"),
|
||||
inner_step_python_type="Type",
|
||||
edges={"e": []},
|
||||
)
|
||||
mock_actor_context.inner_step_type = "Type"
|
||||
mock_actor_context.step_activated = True
|
||||
result = await mock_actor_context.to_dapr_step_info()
|
||||
# check returned dict structure
|
||||
assert "inner_step_python_type" in result
|
||||
assert result["inner_step_python_type"] == "Type"
|
||||
assert result["edges"] == {"e": []}
|
||||
|
||||
|
||||
async def test_handle_message_none_raises(mock_actor_context):
|
||||
"""handle_message with None raises ValueError"""
|
||||
with pytest.raises(ValueError):
|
||||
await mock_actor_context.handle_message(None)
|
||||
|
||||
|
||||
async def test_handle_message_no_function_found_raises(mock_actor_context):
|
||||
"""handle_message raises when function exists in inputs but not in functions dict"""
|
||||
# prepare actor state
|
||||
mock_actor_context.step_activated = True
|
||||
mock_actor_context.functions = {}
|
||||
mock_actor_context.inputs = {"f1": {"a": "v"}}
|
||||
mock_actor_context.initial_inputs = {"f1": {"a": "v"}}
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="S", version="v", id="id"),
|
||||
inner_step_python_type="Type",
|
||||
edges={},
|
||||
)
|
||||
from semantic_kernel.processes.process_message import ProcessMessage
|
||||
|
||||
msg = ProcessMessage(source_id="s", destination_id="d", function_name="f1", values={"a": "v"})
|
||||
with pytest.raises(ProcessFunctionNotFoundException):
|
||||
await mock_actor_context.handle_message(msg)
|
||||
|
||||
|
||||
async def test_handle_message_function_returns_none_emits_error(mock_actor_context):
|
||||
"""handle_message when invoke_function returns None emits an error event"""
|
||||
mock_actor_context.step_activated = True
|
||||
# ensure name property works by setting step_info
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="S", version="v", id="id"),
|
||||
inner_step_python_type="Type",
|
||||
edges={},
|
||||
)
|
||||
# prepare inputs and functions
|
||||
fake_kernel_fn = MagicMock(plugin_name="plug", name="f1")
|
||||
mock_actor_context.functions = {"f1": fake_kernel_fn}
|
||||
mock_actor_context.inputs = {"f1": {"x": 1}}
|
||||
mock_actor_context.initial_inputs = {"f1": {"x": None}}
|
||||
# patch invoke_function to return None
|
||||
mock_actor_context.invoke_function = AsyncMock(return_value=None)
|
||||
# capture emit_event
|
||||
mock_actor_context.emit_event = AsyncMock()
|
||||
from semantic_kernel.processes.process_message import ProcessMessage
|
||||
|
||||
msg = ProcessMessage(source_id="s", destination_id="d", function_name="f1", values={"x": 1})
|
||||
await mock_actor_context.handle_message(msg)
|
||||
# should emit error event
|
||||
emitted = mock_actor_context.emit_event.call_args.args[0]
|
||||
assert isinstance(emitted, KernelProcessEvent)
|
||||
assert emitted.id == "f1.OnError"
|
||||
|
||||
|
||||
async def test_handle_message_success_emits_result_and_resets_inputs(mock_actor_context):
|
||||
"""handle_message successfully invokes function and emits result event"""
|
||||
# setup valid step_info and activation
|
||||
mock_actor_context.step_info = DaprStepInfo(
|
||||
state=KernelProcessStepState(name="S", version="v", id="id"),
|
||||
inner_step_python_type="Type",
|
||||
edges={},
|
||||
)
|
||||
mock_actor_context.step_activated = True
|
||||
fake_kernel_fn = MagicMock(plugin_name="plug", name="f1")
|
||||
mock_actor_context.functions = {"f1": fake_kernel_fn}
|
||||
mock_actor_context.inputs = {"f1": {"x": 1}}
|
||||
mock_actor_context.initial_inputs = {"f1": {"x": None}}
|
||||
|
||||
# define a dummy result class with a value attribute
|
||||
class DummyResult:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
r = DummyResult("out")
|
||||
mock_actor_context.invoke_function = AsyncMock(return_value=r)
|
||||
mock_actor_context.emit_event = AsyncMock()
|
||||
from semantic_kernel.processes.process_message import ProcessMessage
|
||||
|
||||
msg = ProcessMessage(source_id="s", destination_id="d", function_name="f1", values={"x": 1})
|
||||
await mock_actor_context.handle_message(msg)
|
||||
# verify emitted result event and inputs reset
|
||||
emitted = mock_actor_context.emit_event.call_args.args[0]
|
||||
assert emitted.id == "f1.OnResult"
|
||||
assert emitted.data == "out"
|
||||
assert mock_actor_context.inputs["f1"] == {"x": None}
|
||||
@@ -0,0 +1,199 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
|
||||
from semantic_kernel.processes.step_utils import get_step_class_from_qualified_name
|
||||
|
||||
|
||||
class MockValidStep(KernelProcessStep):
|
||||
"""A valid KernelProcessStep subclass for testing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotAStep:
|
||||
"""A class that is NOT a KernelProcessStep subclass."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def test_valid_step_class_loads_successfully():
|
||||
"""Test that a valid KernelProcessStep subclass loads correctly."""
|
||||
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
|
||||
result = get_step_class_from_qualified_name(
|
||||
full_name,
|
||||
allowed_module_prefixes=[MockValidStep.__module__],
|
||||
)
|
||||
assert result is MockValidStep
|
||||
assert issubclass(result, KernelProcessStep)
|
||||
|
||||
|
||||
def test_invalid_format_no_dots_raises_exception():
|
||||
"""Test that a class name without dots raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="Invalid step class name format"):
|
||||
get_step_class_from_qualified_name("NoDots")
|
||||
|
||||
|
||||
def test_empty_module_name_raises_exception():
|
||||
"""Test that empty module name (e.g., '.ClassName') raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="Module name and class name cannot be empty"):
|
||||
get_step_class_from_qualified_name(".ClassName")
|
||||
|
||||
|
||||
def test_empty_class_name_raises_exception():
|
||||
"""Test that empty class name (e.g., 'module.') raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="Module name and class name cannot be empty"):
|
||||
get_step_class_from_qualified_name("module.")
|
||||
|
||||
|
||||
def test_empty_string_raises_exception():
|
||||
"""Test that an empty string raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="Invalid step class name format"):
|
||||
get_step_class_from_qualified_name("")
|
||||
|
||||
|
||||
def test_none_like_empty_raises_exception():
|
||||
"""Test that None-like values raise ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="Invalid step class name format"):
|
||||
get_step_class_from_qualified_name(None) # type: ignore
|
||||
|
||||
|
||||
def test_nonexistent_module_raises_exception():
|
||||
"""Test that a non-existent module raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="Unable to import module"):
|
||||
get_step_class_from_qualified_name("nonexistent_module_xyz123.SomeClass", allowed_module_prefixes=None)
|
||||
|
||||
|
||||
def test_nonexistent_class_in_valid_module_raises_exception():
|
||||
"""Test that a non-existent class in a valid module raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="not found in module"):
|
||||
get_step_class_from_qualified_name("semantic_kernel.kernel.NonExistentClass12345")
|
||||
|
||||
|
||||
def test_non_class_attribute_raises_exception():
|
||||
"""Test that loading a non-class attribute (function) raises ProcessInvalidConfigurationException."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not a class type"):
|
||||
get_step_class_from_qualified_name("semantic_kernel.processes.step_utils.get_fully_qualified_name")
|
||||
|
||||
|
||||
def test_non_step_class_raises_exception():
|
||||
"""Test that a class not inheriting from KernelProcessStep raises exception."""
|
||||
full_name = f"{NotAStep.__module__}.{NotAStep.__name__}"
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
|
||||
get_step_class_from_qualified_name(full_name, allowed_module_prefixes=[NotAStep.__module__])
|
||||
|
||||
|
||||
def test_builtin_class_raises_exception():
|
||||
"""Test that built-in classes like str raise exception (bypassing prefix check to test subclass validation)."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
|
||||
get_step_class_from_qualified_name("builtins.str", allowed_module_prefixes=None)
|
||||
|
||||
|
||||
def test_os_system_prevented():
|
||||
"""Test that os.system is prevented (bypassing prefix check to test type validation)."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not a class type"):
|
||||
get_step_class_from_qualified_name("os.system", allowed_module_prefixes=None)
|
||||
|
||||
|
||||
def test_arbitrary_class_prevented():
|
||||
"""Test that arbitrary classes like subprocess.Popen are prevented (bypassing prefix check)."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
|
||||
get_step_class_from_qualified_name("subprocess.Popen", allowed_module_prefixes=None)
|
||||
|
||||
|
||||
def test_kernel_class_prevented():
|
||||
"""Test that even internal SK classes that aren't steps are prevented."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
|
||||
get_step_class_from_qualified_name("semantic_kernel.kernel.Kernel")
|
||||
|
||||
|
||||
# Module allowlist tests
|
||||
|
||||
|
||||
def test_allowlist_permits_valid_module():
|
||||
"""Test that a valid step class from an allowed module loads successfully."""
|
||||
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
|
||||
# Allow the test module (use actual module name from __module__)
|
||||
module_prefix = MockValidStep.__module__
|
||||
result = get_step_class_from_qualified_name(
|
||||
full_name,
|
||||
allowed_module_prefixes=[module_prefix],
|
||||
)
|
||||
assert result is MockValidStep
|
||||
|
||||
|
||||
def test_allowlist_blocks_disallowed_module():
|
||||
"""Test that a module not in the allowlist is blocked BEFORE import."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
|
||||
get_step_class_from_qualified_name(
|
||||
"os.path",
|
||||
allowed_module_prefixes=["semantic_kernel.", "myapp."],
|
||||
)
|
||||
|
||||
|
||||
def test_allowlist_blocks_dangerous_module():
|
||||
"""Test that dangerous modules are blocked when allowlist is provided."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
|
||||
get_step_class_from_qualified_name(
|
||||
"subprocess.Popen",
|
||||
allowed_module_prefixes=["semantic_kernel."],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_allowlist_blocks_all():
|
||||
"""Test that an empty allowlist blocks all modules."""
|
||||
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
|
||||
get_step_class_from_qualified_name(full_name, allowed_module_prefixes=[])
|
||||
|
||||
|
||||
def test_none_allowlist_allows_all():
|
||||
"""Test that None allowlist allows any module (explicit opt-out)."""
|
||||
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
|
||||
result = get_step_class_from_qualified_name(full_name, allowed_module_prefixes=None)
|
||||
assert result is MockValidStep
|
||||
|
||||
|
||||
def test_default_allowlist_blocks_non_sk_modules():
|
||||
"""Test that the default allowlist only permits semantic_kernel modules."""
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
|
||||
get_step_class_from_qualified_name("subprocess.Popen")
|
||||
|
||||
|
||||
def test_default_allowlist_permits_sk_modules():
|
||||
"""Test that the default allowlist permits semantic_kernel modules."""
|
||||
full_name = "semantic_kernel.processes.kernel_process.kernel_process_step.KernelProcessStep"
|
||||
result = get_step_class_from_qualified_name(full_name)
|
||||
assert result is KernelProcessStep
|
||||
|
||||
|
||||
def test_allowlist_prefix_matching():
|
||||
"""Test that allowlist uses boundary-aware prefix matching correctly."""
|
||||
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
|
||||
# Exact module name match works
|
||||
result = get_step_class_from_qualified_name(
|
||||
full_name,
|
||||
allowed_module_prefixes=[MockValidStep.__module__],
|
||||
)
|
||||
assert result is MockValidStep
|
||||
|
||||
# Arbitrary substring prefix without dot boundary does not match
|
||||
short_prefix = MockValidStep.__module__[:4] # e.g. "test"
|
||||
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
|
||||
get_step_class_from_qualified_name(
|
||||
full_name,
|
||||
allowed_module_prefixes=[short_prefix],
|
||||
)
|
||||
|
||||
|
||||
def test_allowlist_multiple_prefixes():
|
||||
"""Test that multiple allowed prefixes work correctly."""
|
||||
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
|
||||
module_prefix = MockValidStep.__module__
|
||||
result = get_step_class_from_qualified_name(
|
||||
full_name,
|
||||
allowed_module_prefixes=["semantic_kernel.", "myapp.", module_prefix],
|
||||
)
|
||||
assert result is MockValidStep
|
||||
Reference in New Issue
Block a user