Files
a433c483c1 fix: document bootstrap entrypoints in examples (fixes #71) (#96)
* feat: standardize bootstrap entrypoint to 'aiui run app.py' across examples

Standardizes platform bootstrap patterns across examples per issue #71:

- Update examples README table to use 'aiui run app.py' consistently
- Update docstrings in examples 10, 12, 19-21 to show 'aiui run' instead of 'python app.py'
- Remove fragmented bootstrap approaches (uvicorn.run, asyncio patterns)
- Enforce single bootstrap SSOT as designed in CLI

Co-authored-by: Mervin Praison <[email protected]>

* feat: consolidate bootstrap entrypoints - auto-detect gateway apps (fixes #71)

- Add bootstrap detection in CLI run command
- Auto-detect gateway pattern (async main + AIUIGateway imports)
- Auto-switch to praisonai backend for gateway apps
- Delegate to user-defined main() when present
- Remove sys.path hacks from example 14
- Update examples README to show unified aiui run entrypoint
- Add BOOTSTRAP.md documentation for migration patterns

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve critical bootstrap entrypoint issues

- Fix AST parsing bug in gateway auto-detection (cli.py:1419)
- Revert background bots (email-bot, agentmail-bot) to python app.py
- Revert custom UI apps (agent-dashboard) to python app.py
- Update README.md to reflect correct run commands
- Fix import formatting and remove unused imports

These changes ensure that:
1. Gateway auto-detection works correctly by properly checking ast.alias.name
2. Background bots execute their __main__ blocks and don't start unnecessary web servers
3. Custom UI apps register their custom routes in __main__ blocks
4. Documentation matches actual working run commands

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 16:36:35 +01:00

348 lines
15 KiB
Python

"""Unit tests for Semantic Kernel integration."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from praisonaiui.integrations.semantic_kernel import AiuiSemanticKernelFilter
class TestAiuiSemanticKernelFilter:
"""Test the Semantic Kernel function invocation filter."""
@pytest.fixture
def filter_instance(self):
"""Create a filter instance."""
return AiuiSemanticKernelFilter()
@pytest.fixture
def mock_step(self):
"""Create a mock Step instance."""
step = MagicMock()
step._id = "test-sk-step-123"
step.__aenter__ = AsyncMock()
step.__aexit__ = AsyncMock()
step.stream_token = AsyncMock()
return step
@pytest.fixture
def mock_context(self):
"""Create a mock SK function context."""
context = MagicMock()
context.function = MagicMock()
context.function.name = "test_function"
context.function.plugin_name = "test_plugin"
context.arguments = {"arg1": "value1", "arg2": "value2"}
return context
@pytest.fixture
def mock_next_filter(self):
"""Create a mock next filter function."""
return AsyncMock(return_value="filter_result")
def test_init(self, filter_instance):
"""Test filter initialization."""
assert filter_instance._context_to_step == {}
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_function_invocation_success(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test successful function invocation."""
mock_step_class.return_value = mock_step
result = await filter_instance.on_function_invocation(mock_context, mock_next_filter)
# Verify Step was created with correct parameters
mock_step_class.assert_called_once_with(
name="🔧 SK Function: test_plugin.test_function",
type="tool_call",
parent=None,
metadata={
"function_name": "test_function",
"plugin_name": "test_plugin",
"arguments": {"arg1": "value1", "arg2": "value2"}
}
)
# Verify step lifecycle
mock_step.__aenter__.assert_called_once()
mock_step.__aexit__.assert_called_once_with(None, None, None)
# Verify next filter was called
mock_next_filter.assert_called_once_with(mock_context)
# Verify result is returned
assert result == "filter_result"
# Verify streaming calls were made
assert mock_step.stream_token.call_count >= 2 # At least function name and result
# Verify cleanup
assert len(filter_instance._context_to_step) == 0
assert len(filter_instance._context_to_step) == 0
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_function_invocation_no_plugin_name(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test function invocation without plugin name."""
mock_step_class.return_value = mock_step
mock_context.function.plugin_name = None
await filter_instance.on_function_invocation(mock_context, mock_next_filter)
# Verify Step was created with correct name (no plugin prefix)
mock_step_class.assert_called_once_with(
name="🔧 SK Function: test_function",
type="tool_call",
parent=None,
metadata={
"function_name": "test_function",
"plugin_name": None,
"arguments": {"arg1": "value1", "arg2": "value2"}
}
)
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_function_invocation_error(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test function invocation with error."""
mock_step_class.return_value = mock_step
error = ValueError("Test SK function error")
mock_next_filter.side_effect = error
with pytest.raises(ValueError) as exc_info:
await filter_instance.on_function_invocation(mock_context, mock_next_filter)
assert exc_info.value is error
# Verify step was ended with error
mock_step.__aenter__.assert_called_once()
mock_step.__aexit__.assert_called_once_with(ValueError, error, None)
# Verify cleanup happened even with error
assert len(filter_instance._context_to_step) == 0
assert len(filter_instance._context_to_step) == 0
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_function_invocation_no_result(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test function invocation with no result."""
mock_step_class.return_value = mock_step
mock_next_filter.return_value = None
result = await filter_instance.on_function_invocation(mock_context, mock_next_filter)
# Verify None result is handled gracefully
assert result is None
# Verify step completed successfully
mock_step.__aexit__.assert_called_once_with(None, None, None)
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_auto_function_invocation_success(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test successful auto function invocation."""
mock_step_class.return_value = mock_step
result = await filter_instance.on_auto_function_invocation(mock_context, mock_next_filter)
# Verify Step was created with auto function name
mock_step_class.assert_called_once_with(
name="🤖 Auto SK Function: test_plugin.test_function",
type="sub_agent", # Auto calls use sub_agent type
parent=None,
metadata={
"function_name": "test_function",
"plugin_name": "test_plugin",
"arguments": {"arg1": "value1", "arg2": "value2"},
"auto_invocation": True
}
)
# Verify step lifecycle
mock_step.__aenter__.assert_called_once()
mock_step.__aexit__.assert_called_once_with(None, None, None)
# Verify result is returned
assert result == "filter_result"
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_auto_function_invocation_no_plugin(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test auto function invocation without plugin name."""
mock_step_class.return_value = mock_step
mock_context.function.plugin_name = None
await filter_instance.on_auto_function_invocation(mock_context, mock_next_filter)
# Verify Step was created with correct auto name (no plugin prefix)
mock_step_class.assert_called_once_with(
name="🤖 Auto SK Function: test_function",
type="sub_agent",
parent=None,
metadata={
"function_name": "test_function",
"plugin_name": None,
"arguments": {"arg1": "value1", "arg2": "value2"},
"auto_invocation": True
}
)
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_on_auto_function_invocation_error(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test auto function invocation with error."""
mock_step_class.return_value = mock_step
error = RuntimeError("Auto invocation failed")
mock_next_filter.side_effect = error
with pytest.raises(RuntimeError) as exc_info:
await filter_instance.on_auto_function_invocation(mock_context, mock_next_filter)
assert exc_info.value is error
# Verify step was ended with error
mock_step.__aexit__.assert_called_once_with(RuntimeError, error, None)
@pytest.mark.asyncio
async def test_on_function_invoking_alias(self, filter_instance, mock_context, mock_next_filter):
"""Test on_function_invoking alias method."""
with patch.object(filter_instance, 'on_function_invocation', new_callable=AsyncMock) as mock_on_function_invocation:
mock_on_function_invocation.return_value = "alias_result"
result = await filter_instance.on_function_invoking(mock_context, mock_next_filter)
# Verify alias calls the main method
mock_on_function_invocation.assert_called_once_with(mock_context, mock_next_filter)
assert result == "alias_result"
@pytest.mark.asyncio
async def test_on_function_invoked(self, filter_instance, mock_context, mock_next_filter):
"""Test on_function_invoked post-invocation method."""
result = await filter_instance.on_function_invoked(mock_context, mock_next_filter)
# Should just pass through to next filter
mock_next_filter.assert_called_once_with(mock_context)
assert result == "filter_result"
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_nested_function_calls(self, mock_step_class, filter_instance):
"""Test nested function calls maintain parent-child relationships."""
parent_step = MagicMock()
child_step = MagicMock()
parent_step.__aenter__ = AsyncMock()
parent_step.__aexit__ = AsyncMock()
parent_step.stream_token = AsyncMock()
child_step.__aenter__ = AsyncMock()
child_step.__aexit__ = AsyncMock()
child_step.stream_token = AsyncMock()
# First call returns parent step, second call returns child step
mock_step_class.side_effect = [parent_step, child_step]
# Mock contexts
parent_context = MagicMock()
parent_context.function.name = "parent_function"
parent_context.function.plugin_name = "plugin"
parent_context.arguments = {}
child_context = MagicMock()
child_context.function.name = "child_function"
child_context.function.plugin_name = "plugin"
child_context.arguments = {}
# Mock nested next filters
async def parent_next_filter(context):
# This simulates a nested call during parent execution
child_next_filter = AsyncMock(return_value="child_result")
return await filter_instance.on_function_invocation(child_context, child_next_filter)
# Execute nested calls
await filter_instance.on_function_invocation(parent_context, parent_next_filter)
# Verify both steps were created
assert mock_step_class.call_count == 2
# SK doesn't provide explicit parent relationships in filters
# Both steps appear as top-level (parent=None)
child_call_args = mock_step_class.call_args_list[1]
assert child_call_args[1]["parent"] is None
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_streaming_behavior(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test detailed streaming behavior."""
mock_step_class.return_value = mock_step
mock_context.arguments = {"query": "test query", "limit": 10}
await filter_instance.on_function_invocation(mock_context, mock_next_filter)
# Verify specific streaming calls
stream_calls = [call[0][0] for call in mock_step.stream_token.call_args_list]
# Should have at least function call, arguments, and result
assert any("test_function" in call for call in stream_calls)
assert any("Arguments:" in call for call in stream_calls)
assert any("Result:" in call for call in stream_calls)
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_long_result_truncation(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test that long results are truncated in streaming."""
mock_step_class.return_value = mock_step
# Create a very long result
long_result = "x" * 500 # 500 characters
mock_next_filter.return_value = long_result
await filter_instance.on_function_invocation(mock_context, mock_next_filter)
# Find the result streaming call
stream_calls = [call[0][0] for call in mock_step.stream_token.call_args_list]
result_calls = [call for call in stream_calls if call.startswith("Result:")]
assert len(result_calls) == 1
# Should be truncated to 300 characters + "Result: " prefix
assert len(result_calls[0]) <= 310 # Some margin for "Result: "
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_long_arguments_truncation(self, mock_step_class, filter_instance, mock_step, mock_context, mock_next_filter):
"""Test that long arguments are truncated in streaming."""
mock_step_class.return_value = mock_step
# Create long arguments
mock_context.arguments = {"long_arg": "y" * 300}
await filter_instance.on_function_invocation(mock_context, mock_next_filter)
# Find the arguments streaming call
stream_calls = [call[0][0] for call in mock_step.stream_token.call_args_list]
arg_calls = [call for call in stream_calls if call.startswith("Arguments:")]
assert len(arg_calls) == 1
# Should be truncated to 200 characters + "Arguments: " prefix
assert len(arg_calls[0]) <= 220 # Some margin for "Arguments: "
@pytest.mark.asyncio
@patch('praisonaiui.integrations.semantic_kernel.Step')
async def test_context_id_collision_handling(self, mock_step_class, filter_instance, mock_step):
"""Test handling of context ID collisions (edge case)."""
mock_step_class.return_value = mock_step
# Create two contexts that might have the same id() due to object reuse
context1 = MagicMock()
context1.function.name = "func1"
context1.function.plugin_name = "plugin1"
context1.arguments = {}
# Simulate contexts with same id (very unlikely but possible)
with patch('builtins.id', return_value=12345):
next_filter1 = AsyncMock(return_value="result1")
result1 = await filter_instance.on_function_invocation(context1, next_filter1)
# Should complete successfully despite potential ID collision
assert result1 == "result1"
assert len(filter_instance._context_to_step) == 0 # Should be cleaned up