565 lines
20 KiB
Python
565 lines
20 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
from queue import Queue
|
|
from typing import cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from semantic_kernel import Kernel
|
|
from semantic_kernel.exceptions.kernel_exceptions import KernelException
|
|
from semantic_kernel.exceptions.process_exceptions import (
|
|
ProcessFunctionNotFoundException,
|
|
)
|
|
from semantic_kernel.functions import KernelFunction
|
|
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
|
|
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
|
|
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
|
|
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
|
|
from semantic_kernel.processes.kernel_process.kernel_process_function_target import KernelProcessFunctionTarget
|
|
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
|
|
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
|
|
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
|
|
from semantic_kernel.processes.local_runtime.local_event import LocalEvent
|
|
from semantic_kernel.processes.local_runtime.local_message import LocalMessage
|
|
from semantic_kernel.processes.local_runtime.local_step import LocalStep
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_process_step_state():
|
|
"""Fixture for creating a mocked KernelProcessStepState object."""
|
|
return KernelProcessStepState(name="my_step", id="123", state=None, version="1.0")
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_process_step_state_without_id():
|
|
"""Fixture for creating a mocked KernelProcessStepState object without id."""
|
|
return KernelProcessStepState(name="my_step", id=None, state=None, version="1.0")
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_process_step_info(mocked_process_step_state):
|
|
"""Fixture for creating a mocked KernelProcessStepInfo object."""
|
|
return KernelProcessStepInfo(inner_step_type=AsyncMock(spec=type), state=mocked_process_step_state, output_edges={})
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_process_step_info_without_id(mocked_process_step_state_without_id):
|
|
"""Fixture for creating a mocked KernelProcessStepInfo object."""
|
|
return KernelProcessStepInfo(
|
|
inner_step_type=AsyncMock(spec=type), state=mocked_process_step_state_without_id, output_edges={}
|
|
)
|
|
|
|
|
|
async def test_parse_initial_configuration_adds_ids_if_missing():
|
|
"""Test that parse_initial_configuration sets the step_info.state.id if it is None."""
|
|
edge = KernelProcessEdge(source_step_id="s1", output_target=AsyncMock(spec=KernelProcessFunctionTarget))
|
|
data = {
|
|
"step_info": KernelProcess(
|
|
state=KernelProcessState(name="test_step", version="1.0"), # noqa: F821
|
|
steps=[AsyncMock(spec=KernelProcessStepInfo)],
|
|
edges={"test_event": [edge]},
|
|
),
|
|
}
|
|
|
|
# Call the parse_initial_configuration
|
|
processed_data = LocalStep.parse_initial_configuration(data) # type: ignore
|
|
|
|
# Assert that it sets the id
|
|
assert processed_data["step_state"].id is not None
|
|
assert processed_data["step_state"].id != ""
|
|
assert processed_data["event_namespace"] == f"{processed_data['step_state'].name}_{processed_data['step_state'].id}"
|
|
assert "output_edges" in processed_data
|
|
assert processed_data["output_edges"] == {"test_event": [edge]}
|
|
|
|
|
|
async def test_parse_initial_configuration_id_already_set():
|
|
"""Test that parse_initial_configuration does not overwrite step_info.state.id if already set."""
|
|
data = {
|
|
"step_info": KernelProcess(
|
|
state=KernelProcessState(name="test_step", id="test_id_set", version="1.0"), # noqa: F821
|
|
steps=[AsyncMock(spec=KernelProcessStepInfo)],
|
|
),
|
|
}
|
|
|
|
# Call the parse_initial_configuration
|
|
processed_data = LocalStep.parse_initial_configuration(data) # type: ignore
|
|
|
|
assert processed_data["step_state"].id is not None
|
|
assert processed_data["step_state"].id == "test_id_set"
|
|
|
|
assert processed_data["event_namespace"] == f"{processed_data['step_state'].name}_{processed_data['step_state'].id}"
|
|
assert "output_edges" in processed_data
|
|
|
|
|
|
def test_name_property(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test that the name property returns the name from the step_info.state."""
|
|
step = LocalStep(
|
|
kernel=MagicMock(spec=Kernel),
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
assert step.name == "my_step"
|
|
|
|
|
|
def test_id_property_with_id(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test that the id property returns the ID if it is available."""
|
|
step = LocalStep(
|
|
kernel=MagicMock(spec=Kernel),
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
assert step.id == "123"
|
|
|
|
|
|
def test_id_property_without_id(mocked_process_step_state_without_id, mocked_process_step_info_without_id):
|
|
"""Test that the id property returns an empty string if ID is None."""
|
|
step = LocalStep(
|
|
kernel=MagicMock(spec=Kernel),
|
|
step_info=mocked_process_step_info_without_id,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state_without_id,
|
|
factories={},
|
|
)
|
|
|
|
assert step.id == ""
|
|
|
|
|
|
async def test_handle_message_raises_exception_when_message_is_none(
|
|
mocked_process_step_state, mocked_process_step_info
|
|
):
|
|
"""Test handle_message raises ValueError when message is None."""
|
|
step = LocalStep(
|
|
kernel=MagicMock(spec=Kernel),
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
with pytest.raises(ValueError) as exc:
|
|
await step.handle_message(cast(LocalMessage, None))
|
|
assert "The message is None." in str(exc.value)
|
|
|
|
|
|
async def test_handle_message_initializes_step_if_not_initialized(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test handle_message calls initialize_step if the step isn't yet initialized."""
|
|
mock_kernel = MagicMock(spec=Kernel)
|
|
step = LocalStep(
|
|
kernel=mock_kernel,
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
functions={"other_func": AsyncMock(spec=KernelFunction)},
|
|
inputs={
|
|
"other_func": {"param": "ready_value"},
|
|
},
|
|
initial_inputs={
|
|
"other_func": {"param": None},
|
|
},
|
|
initialize_task=False,
|
|
)
|
|
|
|
with patch.object(LocalStep, "initialize_step") as mock_initialize_step:
|
|
msg = LocalMessage(
|
|
source_id="source",
|
|
destination_id="dest",
|
|
function_name="other_func",
|
|
values={"param": "value"},
|
|
target_event_id=None,
|
|
target_event_data=None,
|
|
)
|
|
|
|
await step.handle_message(msg)
|
|
|
|
mock_initialize_step.assert_awaited_once()
|
|
assert step.initialize_task is True
|
|
|
|
|
|
async def test_handle_message_raises_if_functions_not_initialized(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test handle_message raises ValueError if step is not properly initialized."""
|
|
# We simulate that after initialization, the step still doesn't have `functions`.
|
|
mock_kernel = AsyncMock(spec=Kernel)
|
|
|
|
step = LocalStep(
|
|
kernel=mock_kernel, # type: ignore
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
initialize_task=False,
|
|
)
|
|
|
|
# Force the initialize_step to not fill in functions.
|
|
async def mocked_init_step():
|
|
step.functions = {}
|
|
step.inputs = {}
|
|
step.initial_inputs = {}
|
|
|
|
with patch.object(
|
|
LocalStep,
|
|
"initialize_step",
|
|
return_value=AsyncMock(side_effect=mocked_init_step),
|
|
):
|
|
msg = LocalMessage(
|
|
source_id="source",
|
|
destination_id="dest",
|
|
function_name="any_func",
|
|
values={"param": "value"},
|
|
)
|
|
|
|
with pytest.raises(ProcessFunctionNotFoundException) as exc:
|
|
await step.handle_message(msg)
|
|
|
|
assert "Function any_func not found in plugin my_step" in str(exc.value)
|
|
|
|
|
|
async def test_handle_message_updates_inputs_and_invokes_function(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test that handle_message updates inputs with message values and invokes the function
|
|
if all parameters are provided."""
|
|
mock_kernel = AsyncMock(spec=Kernel)
|
|
mock_kernel.invoke = AsyncMock(return_value=MagicMock(value="result"))
|
|
|
|
# Create a function that requires one parameter
|
|
mock_function = AsyncMock(spec=KernelFunction)
|
|
mock_function.name = "func"
|
|
mock_function.plugin_name = "test_plugin"
|
|
|
|
mock_function.metadata = AsyncMock(spec=KernelFunctionMetadata)
|
|
mock_function.metadata.name = "func"
|
|
mock_function.metadata.plugin_name = "test_plugin"
|
|
mock_function.metadata.is_prompt = False
|
|
|
|
step = LocalStep(
|
|
kernel=mock_kernel,
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
functions={
|
|
"func": mock_function,
|
|
},
|
|
inputs={"func": {"param": None}},
|
|
initial_inputs={"func": {"param": None}},
|
|
initialize_task=True,
|
|
)
|
|
|
|
with patch.object(LocalStep, "emit_event") as mock_emit_event:
|
|
msg = LocalMessage(
|
|
source_id="source",
|
|
destination_id="dest",
|
|
function_name="func",
|
|
values={"param": "value"},
|
|
)
|
|
|
|
await step.handle_message(msg)
|
|
|
|
# Function invoked with correct arguments
|
|
mock_kernel.invoke.assert_awaited_once()
|
|
mock_emit_event.assert_awaited()
|
|
|
|
assert mock_emit_event.call_args.args[0].id == "func.OnResult"
|
|
|
|
# After invocation, input is reset
|
|
assert step.inputs["func"]["param"] is None
|
|
|
|
|
|
async def test_handle_message_raises_target_function_not_found(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test handle_message raises an exception if the target function is not the one that is invocable."""
|
|
mock_kernel = AsyncMock(spec=Kernel)
|
|
mock_kernel.invoke = AsyncMock(return_value=AsyncMock(value="result"))
|
|
|
|
# Pretend we have two functions, and only "other_func" is fully ready
|
|
step = LocalStep(
|
|
kernel=mock_kernel,
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state, # type: ignore
|
|
factories={},
|
|
functions={"other_func": AsyncMock(spec=KernelFunction)},
|
|
inputs={
|
|
"other_func": {"param": "ready_value"},
|
|
},
|
|
initial_inputs={
|
|
"other_func": {"param": None},
|
|
},
|
|
initialize_task=True,
|
|
)
|
|
|
|
msg = LocalMessage(
|
|
source_id="source",
|
|
destination_id="dest",
|
|
function_name="mismatched_func",
|
|
values={"param": "value"},
|
|
)
|
|
|
|
with pytest.raises(ProcessFunctionNotFoundException) as exc:
|
|
await step.handle_message(msg)
|
|
|
|
assert "Function mismatched_func not found in plugin my_step" in str(exc.value)
|
|
|
|
|
|
async def test_handle_message_raises_function_not_found_if_no_function(
|
|
mocked_process_step_state, mocked_process_step_info
|
|
):
|
|
"""Test handle_message raises ProcessFunctionNotFoundException if the function is not found in the step."""
|
|
mock_kernel = AsyncMock(spec=Kernel)
|
|
mock_kernel.invoke = AsyncMock(return_value=AsyncMock(value="result"))
|
|
|
|
step = LocalStep(
|
|
kernel=mock_kernel,
|
|
step_info=mocked_process_step_info, # type: ignore
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
functions={},
|
|
inputs={"func": {"param": "ready_value"}},
|
|
initial_inputs={"func": {"param": None}},
|
|
initialize_task=True,
|
|
)
|
|
|
|
msg = LocalMessage(
|
|
source_id="source",
|
|
destination_id="dest",
|
|
function_name="func",
|
|
values={"param": "value"},
|
|
)
|
|
|
|
with pytest.raises(ProcessFunctionNotFoundException) as exc:
|
|
await step.handle_message(msg)
|
|
assert "Function func not found in plugin my_step" in str(exc.value)
|
|
|
|
|
|
async def test_handle_message_emits_error_event_on_exception(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test handle_message emits an OnError event when the function invocation raises an exception."""
|
|
mock_kernel = AsyncMock(spec=Kernel)
|
|
mock_kernel.invoke = AsyncMock(side_effect=KernelException("error"))
|
|
|
|
mock_function = AsyncMock(spec=KernelFunction)
|
|
mock_function.name = "func"
|
|
mock_function.plugin_name = "test_plugin"
|
|
|
|
mock_function.metadata = AsyncMock(spec=KernelFunctionMetadata)
|
|
|
|
mock_function.metadata.name = "func"
|
|
mock_function.metadata.plugin_name = "test_plugin"
|
|
mock_function.metadata.is_prompt = False
|
|
|
|
step = LocalStep(
|
|
kernel=mock_kernel, # type: ignore
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
functions={"func": mock_function},
|
|
inputs={"func": {"param": "some_value"}},
|
|
initial_inputs={"func": {"param": None}},
|
|
initialize_task=True,
|
|
)
|
|
|
|
with patch.object(LocalStep, "emit_event") as mock_emit_event:
|
|
msg = LocalMessage(
|
|
source_id="source",
|
|
destination_id="dest",
|
|
function_name="func",
|
|
values={},
|
|
)
|
|
|
|
await step.handle_message(msg)
|
|
|
|
# The event name for error is "func.OnError"
|
|
assert mock_emit_event.await_args is not None
|
|
mock_emit_event.assert_awaited()
|
|
assert mock_emit_event.call_args.args[0].id == "func.OnError"
|
|
|
|
|
|
async def test_invoke_function_calls_kernel_invoke():
|
|
"""Test invoke_function calls the kernel's invoke method with provided arguments."""
|
|
mock_kernel = AsyncMock(spec=Kernel)
|
|
mock_kernel.invoke = AsyncMock()
|
|
|
|
mock_function = AsyncMock(spec=KernelFunction)
|
|
|
|
mock_step_info = AsyncMock(spec=KernelProcessStepInfo)
|
|
mock_step_info.edges = MagicMock(return_value={"edge1": "value1", "edge2": "value2"})
|
|
mock_step_info.state = KernelProcessStepState(name="test", id="step-id", state={}, version="1.0")
|
|
|
|
step = LocalStep(
|
|
kernel=mock_kernel, # type: ignore
|
|
step_info=mock_step_info,
|
|
outgoing_event_queue=Queue(),
|
|
event_namespace="ns",
|
|
step_state=mock_step_info.state,
|
|
factories={},
|
|
)
|
|
|
|
args = {"key": "value"}
|
|
await step.invoke_function(mock_function, mock_kernel, args)
|
|
|
|
mock_kernel.invoke.assert_awaited_once_with(mock_function, **args)
|
|
|
|
|
|
async def test_emit_event_puts_local_event_into_queue():
|
|
"""Test emit_event creates a LocalEvent and puts it into the outgoing_event_queue."""
|
|
queue_obj = Queue()
|
|
mock_step_info = AsyncMock(spec=KernelProcessStepInfo)
|
|
mock_step_info.edges = MagicMock(return_value={"edge1": "value1", "edge2": "value2"})
|
|
mock_step_info.state = KernelProcessStepState(name="test", id="step-id", state={}, version="1.0")
|
|
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel),
|
|
step_info=mock_step_info,
|
|
outgoing_event_queue=queue_obj,
|
|
event_namespace="ns",
|
|
step_state=mock_step_info.state,
|
|
factories={},
|
|
)
|
|
|
|
event = KernelProcessEvent(id="test_event", data="some_data")
|
|
await step.emit_event(event)
|
|
# The queue should contain a LocalEvent
|
|
assert not queue_obj.empty()
|
|
local_event = queue_obj.get()
|
|
assert queue_obj.empty()
|
|
assert isinstance(local_event, LocalEvent)
|
|
assert local_event.inner_event is event
|
|
assert local_event.namespace == "test_step-id"
|
|
|
|
|
|
async def test_emit_local_event_puts_into_queue():
|
|
"""Test emit_local_event directly places the local_event into the queue with updated namespace."""
|
|
queue_obj = Queue()
|
|
mock_step_info = AsyncMock(spec=KernelProcessStepInfo)
|
|
mock_step_info.edges = MagicMock(return_value={"edge1": "value1", "edge2": "value2"})
|
|
mock_step_info.state = KernelProcessStepState(name="test", id="step-id", state={}, version="1.0")
|
|
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel),
|
|
step_info=mock_step_info,
|
|
outgoing_event_queue=queue_obj,
|
|
event_namespace="original_ns",
|
|
step_state=mock_step_info.state,
|
|
factories={},
|
|
)
|
|
|
|
local_event = LocalEvent(namespace="temp", inner_event=KernelProcessEvent(id="evt"))
|
|
await step.emit_local_event(local_event)
|
|
|
|
assert not queue_obj.empty()
|
|
popped = queue_obj.get()
|
|
assert popped is local_event
|
|
# The namespace is updated by scoped_event
|
|
assert popped.namespace == f"{step.name}_{step.id}"
|
|
|
|
|
|
def test_get_all_events_returns_all_events_from_queue(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test get_all_events drains the outgoing_event_queue and returns them."""
|
|
queue_obj = Queue()
|
|
event1 = LocalEvent(namespace="ns1", inner_event=KernelProcessEvent(id="e1"))
|
|
event2 = LocalEvent(namespace="ns2", inner_event=KernelProcessEvent(id="e2"))
|
|
queue_obj.put(event1)
|
|
queue_obj.put(event2)
|
|
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel), # type: ignore
|
|
step_info=mocked_process_step_info,
|
|
outgoing_event_queue=queue_obj,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
events = step.get_all_events()
|
|
assert len(events) == 2
|
|
assert events[0] == event1
|
|
assert events[1] == event2
|
|
# Queue should be empty now
|
|
assert queue_obj.empty()
|
|
|
|
|
|
def test_get_edge_for_event_returns_edge_list(mocked_process_step_state):
|
|
"""Test that get_edge_for_event returns the edges from output_edges that match the event id."""
|
|
edge = KernelProcessEdge(source_step_id="s1", output_target=AsyncMock(spec=KernelProcessFunctionTarget))
|
|
mock_info = KernelProcessStepInfo(
|
|
inner_step_type=AsyncMock(spec=type),
|
|
state=mocked_process_step_state,
|
|
output_edges={"test_event": [edge]},
|
|
)
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel), # type: ignore
|
|
step_info=mock_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
edges = step.get_edge_for_event("test_event")
|
|
assert len(edges) == 1
|
|
assert edges[0] == edge
|
|
|
|
output = step.output_edges["test_event"]
|
|
assert output[0] is edge
|
|
|
|
# For a non-existing event, expect empty list
|
|
assert step.get_edge_for_event("not_found") == []
|
|
|
|
|
|
async def test_to_kernel_process_step_info_initializes_if_needed(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test to_kernel_process_step_info calls initialize_step if not yet done."""
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel),
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
initialize_task=False,
|
|
)
|
|
|
|
with patch.object(LocalStep, "initialize_step") as mock_initialize_step:
|
|
result = await step.to_kernel_process_step_info()
|
|
|
|
mock_initialize_step.assert_awaited_once()
|
|
assert result == mocked_process_step_info
|
|
assert step.initialize_task is True
|
|
|
|
|
|
def test_scoped_event_updates_namespace(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test scoped_event sets the local_event's namespace to name_id."""
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel),
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
some_event = LocalEvent(namespace=None, inner_event=KernelProcessEvent(id="evt"))
|
|
result = step.scoped_event(some_event)
|
|
|
|
assert result.namespace == "my_step_123"
|
|
|
|
|
|
def test_scoped_event_from_kernel_process_creates_scoped_event(mocked_process_step_state, mocked_process_step_info):
|
|
"""Test scoped_event_from_kernel_process creates a local event from the kernel process event
|
|
with the step's scope."""
|
|
step = LocalStep(
|
|
kernel=AsyncMock(spec=Kernel), # type: ignore
|
|
step_info=mocked_process_step_info,
|
|
event_namespace="ns",
|
|
step_state=mocked_process_step_state,
|
|
factories={},
|
|
)
|
|
|
|
kpe = KernelProcessEvent(id="test_id", data="some_data")
|
|
local_event = step.scoped_event_from_kernel_process(kpe)
|
|
|
|
assert local_event.namespace == "my_step_123"
|
|
assert local_event.inner_event == kpe
|
|
assert local_event.inner_event is kpe
|