|
1 | 1 | """Tests for AgentCoreMemorySessionManager.""" |
2 | 2 |
|
| 3 | +import asyncio |
| 4 | +import inspect |
3 | 5 | import logging |
4 | 6 | import time |
5 | 7 | from datetime import datetime, timezone |
|
9 | 11 | from botocore.config import Config as BotocoreConfig |
10 | 12 | from botocore.exceptions import ClientError |
11 | 13 | from strands.agent.agent import Agent |
| 14 | +from strands.experimental.hooks.events import ( |
| 15 | + BidiAfterInvocationEvent, |
| 16 | + BidiAgentInitializedEvent, |
| 17 | + BidiMessageAddedEvent, |
| 18 | +) |
| 19 | +from strands.experimental.hooks.multiagent.events import ( |
| 20 | + AfterMultiAgentInvocationEvent, |
| 21 | + AfterNodeCallEvent, |
| 22 | + MultiAgentInitializedEvent, |
| 23 | +) |
12 | 24 | from strands.hooks import AfterInvocationEvent, MessageAddedEvent |
13 | 25 | from strands.hooks.registry import HookRegistry |
14 | 26 | from strands.types.exceptions import SessionException |
@@ -3580,3 +3592,196 @@ def test_retrieve_customer_context_works(self, mock_memory_client): |
3580 | 3592 |
|
3581 | 3593 | mock_memory_client.retrieve_memories.assert_called_once() |
3582 | 3594 | assert "<user_context>" in mock_agent.messages[0]["content"][0]["text"] |
| 3595 | + |
| 3596 | + |
| 3597 | +class TestAsyncMode: |
| 3598 | + """Tests for async_mode: callbacks must not block the event loop.""" |
| 3599 | + |
| 3600 | + def test_async_mode_defaults_to_false(self, agentcore_config): |
| 3601 | + assert agentcore_config.async_mode is False |
| 3602 | + |
| 3603 | + def test_sync_mode_registers_sync_callbacks(self, mock_memory_client): |
| 3604 | + """async_mode=False: all MessageAddedEvent/AfterInvocationEvent callbacks are sync.""" |
| 3605 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=False) |
| 3606 | + manager = _create_session_manager(config, mock_memory_client) |
| 3607 | + registry = HookRegistry() |
| 3608 | + manager.register_hooks(registry) |
| 3609 | + |
| 3610 | + for event_type in (MessageAddedEvent, AfterInvocationEvent): |
| 3611 | + for cb in registry.get_callbacks_for( |
| 3612 | + event_type(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]}) |
| 3613 | + if event_type is MessageAddedEvent |
| 3614 | + else event_type(agent=Mock()) |
| 3615 | + ): |
| 3616 | + assert not inspect.iscoroutinefunction(cb), f"Sync mode leaked an async callback for {event_type}" |
| 3617 | + |
| 3618 | + def test_async_mode_registers_async_callbacks(self, mock_memory_client): |
| 3619 | + """async_mode=True: MessageAddedEvent and AfterInvocationEvent callbacks are coroutine functions.""" |
| 3620 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=True) |
| 3621 | + manager = _create_session_manager(config, mock_memory_client) |
| 3622 | + registry = HookRegistry() |
| 3623 | + manager.register_hooks(registry) |
| 3624 | + |
| 3625 | + msg_callbacks = registry.get_callbacks_for( |
| 3626 | + MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]}) |
| 3627 | + ) |
| 3628 | + assert msg_callbacks, "No MessageAddedEvent callbacks registered in async mode" |
| 3629 | + assert all(inspect.iscoroutinefunction(cb) for cb in msg_callbacks) |
| 3630 | + |
| 3631 | + after_callbacks = registry.get_callbacks_for(AfterInvocationEvent(agent=Mock())) |
| 3632 | + assert after_callbacks, "No AfterInvocationEvent callbacks registered in async mode" |
| 3633 | + assert all(inspect.iscoroutinefunction(cb) for cb in after_callbacks) |
| 3634 | + |
| 3635 | + async def test_async_mode_does_not_block_event_loop(self, mock_memory_client): |
| 3636 | + """The async hooks run boto3 on a worker thread, so the event loop can make progress concurrently.""" |
| 3637 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3638 | + manager = _create_session_manager(config, mock_memory_client) |
| 3639 | + |
| 3640 | + # Simulate each sync session-manager method blocking on boto3. |
| 3641 | + def slow_append_message(message, agent, **kwargs): |
| 3642 | + time.sleep(0.2) |
| 3643 | + |
| 3644 | + def slow_sync_agent(agent, **kwargs): |
| 3645 | + time.sleep(0.2) |
| 3646 | + |
| 3647 | + manager.append_message = slow_append_message |
| 3648 | + manager.sync_agent = slow_sync_agent |
| 3649 | + |
| 3650 | + registry = HookRegistry() |
| 3651 | + manager.register_hooks(registry) |
| 3652 | + |
| 3653 | + persist_callbacks = [ |
| 3654 | + cb |
| 3655 | + for cb in registry.get_callbacks_for( |
| 3656 | + MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]}) |
| 3657 | + ) |
| 3658 | + if asyncio.iscoroutinefunction(cb) |
| 3659 | + ] |
| 3660 | + assert persist_callbacks |
| 3661 | + |
| 3662 | + event = MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "hello"}]}) |
| 3663 | + |
| 3664 | + # Ticker proves the event loop made progress while the hook awaited to_thread. |
| 3665 | + ticks = 0 |
| 3666 | + |
| 3667 | + async def ticker(): |
| 3668 | + nonlocal ticks |
| 3669 | + while True: |
| 3670 | + await asyncio.sleep(0.01) |
| 3671 | + ticks += 1 |
| 3672 | + |
| 3673 | + ticker_task = asyncio.create_task(ticker()) |
| 3674 | + try: |
| 3675 | + # Run the persist callback (append_message + sync_agent); both sleep 0.2s on a worker thread. |
| 3676 | + await persist_callbacks[0](event) |
| 3677 | + finally: |
| 3678 | + ticker_task.cancel() |
| 3679 | + |
| 3680 | + assert ticks > 5, f"Event loop was blocked; only {ticks} ticks recorded" |
| 3681 | + |
| 3682 | + async def test_async_mode_batching_registers_flush_callback(self, mock_memory_client): |
| 3683 | + """async_mode=True with batch_size>1: AfterInvocationEvent gets both sync_agent and flush callbacks.""" |
| 3684 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=True) |
| 3685 | + manager = _create_session_manager(config, mock_memory_client) |
| 3686 | + registry = HookRegistry() |
| 3687 | + manager.register_hooks(registry) |
| 3688 | + |
| 3689 | + after_callbacks = list(registry.get_callbacks_for(AfterInvocationEvent(agent=Mock()))) |
| 3690 | + assert len(after_callbacks) == 2 |
| 3691 | + assert all(asyncio.iscoroutinefunction(cb) for cb in after_callbacks) |
| 3692 | + |
| 3693 | + def test_async_mode_registers_multi_agent_callbacks(self, mock_memory_client): |
| 3694 | + """async_mode=True: multi-agent events get async callbacks (parity with sync mode).""" |
| 3695 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3696 | + manager = _create_session_manager(config, mock_memory_client) |
| 3697 | + registry = HookRegistry() |
| 3698 | + manager.register_hooks(registry) |
| 3699 | + |
| 3700 | + for event_type in (MultiAgentInitializedEvent, AfterNodeCallEvent, AfterMultiAgentInvocationEvent): |
| 3701 | + callbacks = registry._registered_callbacks.get(event_type, []) |
| 3702 | + assert callbacks, f"No callbacks registered for {event_type.__name__}" |
| 3703 | + assert all(asyncio.iscoroutinefunction(cb) for cb in callbacks) |
| 3704 | + |
| 3705 | + def test_async_mode_logs_sync_invocation_warning(self, mock_memory_client, caplog): |
| 3706 | + """async_mode=True emits a WARNING at register_hooks time pointing users to stream_async/invoke_async.""" |
| 3707 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3708 | + manager = _create_session_manager(config, mock_memory_client) |
| 3709 | + registry = HookRegistry() |
| 3710 | + |
| 3711 | + with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.memory.integrations.strands.session_manager"): |
| 3712 | + manager.register_hooks(registry) |
| 3713 | + |
| 3714 | + assert any("async_mode=True" in rec.message and "stream_async" in rec.message for rec in caplog.records) |
| 3715 | + |
| 3716 | + def test_async_mode_registers_bidi_agent_callbacks(self, mock_memory_client): |
| 3717 | + """async_mode=True: BidiAgent events get callbacks; init stays sync, others are async.""" |
| 3718 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3719 | + manager = _create_session_manager(config, mock_memory_client) |
| 3720 | + registry = HookRegistry() |
| 3721 | + manager.register_hooks(registry) |
| 3722 | + |
| 3723 | + # BidiAgentInitializedEvent dispatches via the sync hook path, so its callback must NOT be a coroutine. |
| 3724 | + init_callbacks = registry._registered_callbacks.get(BidiAgentInitializedEvent, []) |
| 3725 | + assert init_callbacks, "No callbacks registered for BidiAgentInitializedEvent" |
| 3726 | + assert not any(asyncio.iscoroutinefunction(cb) for cb in init_callbacks) |
| 3727 | + |
| 3728 | + # BidiMessageAddedEvent and BidiAfterInvocationEvent dispatch via invoke_callbacks_async, |
| 3729 | + # so their callbacks should be async to keep the event loop unblocked. |
| 3730 | + for event_type in (BidiMessageAddedEvent, BidiAfterInvocationEvent): |
| 3731 | + callbacks = registry._registered_callbacks.get(event_type, []) |
| 3732 | + assert callbacks, f"No callbacks registered for {event_type.__name__}" |
| 3733 | + assert all(asyncio.iscoroutinefunction(cb) for cb in callbacks) |
| 3734 | + |
| 3735 | + |
| 3736 | +class TestFlushAgentStatesRaceCondition: |
| 3737 | + """Tests for the copy-and-clear-under-one-lock fix in _flush_agent_states_only.""" |
| 3738 | + |
| 3739 | + def test_flush_agent_states_does_not_drop_concurrent_appends(self, batching_session_manager, mock_memory_client): |
| 3740 | + """States appended during the network I/O window must survive the flush.""" |
| 3741 | + states_appended_during_flush = [] |
| 3742 | + |
| 3743 | + # Pre-populate the buffer with one state to force a flush. |
| 3744 | + initial_agent = SessionAgent( |
| 3745 | + agent_id="agent-1", |
| 3746 | + state={"description": "initial"}, |
| 3747 | + conversation_manager_state={}, |
| 3748 | + ) |
| 3749 | + batching_session_manager.create_agent("test-session-456", initial_agent) |
| 3750 | + assert batching_session_manager.pending_agent_state_count() == 1 |
| 3751 | + |
| 3752 | + # Simulate a concurrent create_agent during the boto3 call. The mock fires |
| 3753 | + # while the buffer is being flushed — i.e. between the copy and any clear — |
| 3754 | + # so a second append must NOT be lost. |
| 3755 | + def create_event_and_append_concurrently(**kwargs): |
| 3756 | + new_agent = SessionAgent( |
| 3757 | + agent_id="agent-2", |
| 3758 | + state={"description": "appended-mid-flush"}, |
| 3759 | + conversation_manager_state={}, |
| 3760 | + ) |
| 3761 | + batching_session_manager.create_agent("test-session-456", new_agent) |
| 3762 | + states_appended_during_flush.append(new_agent) |
| 3763 | + return {"eventId": "event_during_flush"} |
| 3764 | + |
| 3765 | + mock_memory_client.gmdp_client.create_event.side_effect = create_event_and_append_concurrently |
| 3766 | + |
| 3767 | + batching_session_manager._flush_agent_states_only() |
| 3768 | + |
| 3769 | + # The state appended during the flush must still be in the buffer afterwards. |
| 3770 | + assert states_appended_during_flush, "Test setup error: concurrent append did not run" |
| 3771 | + assert batching_session_manager.pending_agent_state_count() == 1, ( |
| 3772 | + "State appended during flush was dropped — copy/clear is not atomic" |
| 3773 | + ) |
| 3774 | + |
| 3775 | + def test_flush_agent_states_failure_restores_buffer(self, batching_session_manager, mock_memory_client): |
| 3776 | + """A failed flush must restore the originally-buffered states (no data loss).""" |
| 3777 | + mock_memory_client.gmdp_client.create_event.side_effect = Exception("API Error") |
| 3778 | + |
| 3779 | + agent = SessionAgent(agent_id="agent-1", state={"description": "v1"}, conversation_manager_state={}) |
| 3780 | + batching_session_manager.create_agent("test-session-456", agent) |
| 3781 | + assert batching_session_manager.pending_agent_state_count() == 1 |
| 3782 | + |
| 3783 | + with pytest.raises(SessionException): |
| 3784 | + batching_session_manager._flush_agent_states_only() |
| 3785 | + |
| 3786 | + # State must be back in the buffer for retry. |
| 3787 | + assert batching_session_manager.pending_agent_state_count() == 1 |
0 commit comments