From 387724c35592fa1ef058c54dabd8e7a19436df6a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 16 May 2026 11:14:56 +0000 Subject: [PATCH] test(agent): add tests to ensure goal state does not leak across sessions --- tests/agent/test_context_builder.py | 25 ++++++++++++++ tests/agent/test_loop_save_turn.py | 53 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index 93ce9cb46..0206d0986 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -299,6 +299,31 @@ class TestBuildMessages: assert "Goal (active):" in user_msg assert "Finish docs migration." in user_msg + def test_goal_state_does_not_leak_without_session_metadata(self, tmp_path): + builder = _builder(tmp_path) + other_session_meta = { + GOAL_STATE_KEY: {"status": "active", "objective": "Other chat goal."}, + } + + with_goal = builder.build_messages( + [], + "hi", + channel="websocket", + chat_id="chat-a", + session_metadata=other_session_meta, + ) + without_goal = builder.build_messages( + [], + "hi", + channel="websocket", + chat_id="chat-b", + session_metadata={}, + ) + + assert "Other chat goal." in str(with_goal[-1]["content"]) + assert "Other chat goal." not in str(without_goal[-1]["content"]) + assert "Goal (active):" not in str(without_goal[-1]["content"]) + def test_consecutive_same_role_merged(self, tmp_path): builder = _builder(tmp_path) history = [{"role": "user", "content": "previous user message"}] diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index c33ecf422..ed78e7192 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -9,6 +9,7 @@ from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse +from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session from nanobot.utils.webui_titles import ( WEBUI_SESSION_METADATA_KEY, @@ -493,6 +494,58 @@ async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777" +@pytest.mark.asyncio +async def test_process_message_uses_explicit_session_metadata_for_goal_context( + tmp_path: Path, +) -> None: + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + chat_session = loop.sessions.get_or_create("websocket:chat-with-goal") + chat_session.metadata[GOAL_STATE_KEY] = { + "status": "active", + "objective": "This chat goal must not leak into heartbeat.", + } + loop.sessions.save(chat_session) + system_session = loop.sessions.get_or_create("heartbeat") + system_session.metadata = {} + loop.sessions.save(system_session) + + loop.context.build_messages = MagicMock( # type: ignore[method-assign] + return_value=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "runtime + heartbeat"}, + ] + ) + loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign] + "ok", + [], + [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "runtime + heartbeat"}, + {"role": "assistant", "content": "ok"}, + ], + "stop", + False, + )) + + result = await loop._process_message( + InboundMessage( + channel="websocket", + sender_id="heartbeat", + chat_id="chat-with-goal", + content="heartbeat work", + ), + session_key="heartbeat", + ) + + assert result is not None + assert result.content == "ok" + kwargs = loop.context.build_messages.call_args.kwargs + assert kwargs["chat_id"] == "chat-with-goal" + assert kwargs["session_metadata"] is system_session.metadata + assert GOAL_STATE_KEY not in kwargs["session_metadata"] + + def test_set_tool_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) spawn_tool = loop.tools.get("spawn")