1111
1212import importlib
1313import inspect
14+ import re
1415import sys
1516from types import ModuleType
1617from typing import Any , Dict , List , Optional
@@ -63,24 +64,27 @@ def test_eight_submodules_exist(self):
6364
6465
6566class TestNoCircularImports :
66- """Import each submodule independently — if circular deps exist these will blow up."""
67+ """Import each submodule with the full grounding subtree evicted from sys.modules.
68+
69+ Popping only the target module is insufficient — circular dependencies only
70+ manifest when *both* sides of the cycle are absent from the module cache.
71+ """
6772
6873 @pytest .mark .parametrize ("module_name" , EXPECTED_SUBMODULES )
6974 def test_independent_import (self , module_name : str ):
70- # Force a fresh import by removing cached module if present
71- cached = sys .modules .pop (module_name , None )
75+ prefix = "openspace.agents.grounding"
76+ saved = { k : sys .modules .pop (k ) for k in list ( sys . modules ) if k . startswith ( prefix )}
7277 try :
7378 importlib .import_module (module_name )
7479 finally :
75- if cached is not None :
76- sys .modules [module_name ] = cached
80+ sys .modules .update (saved )
7781
7882
7983# ── 3. Backward-compatible import paths ──────────────────────────────
8084
8185
8286class TestBackwardCompatibility :
83- """Callers using the old import path must still work."""
87+ """Callers using the old import path must still work and resolve to the same class ."""
8488
8589 def test_import_from_agents_module (self ):
8690 from openspace .agents import GroundingAgent
@@ -97,6 +101,14 @@ def test_import_from_top_level(self):
97101
98102 assert inspect .isclass (GroundingAgent )
99103
104+ def test_all_import_paths_resolve_to_same_class (self ):
105+ """Identity check — a broken re-export that duplicates the class would fail here."""
106+ from openspace import GroundingAgent as GA_top
107+ from openspace .agents import GroundingAgent as GA_agents
108+ from openspace .agents .grounding_agent import GroundingAgent as GA_module
109+
110+ assert GA_top is GA_agents is GA_module
111+
100112
101113# ── 4. Facade delegation ────────────────────────────────────────────
102114
@@ -117,7 +129,15 @@ def _make_agent(self):
117129
118130 with patch ("openspace.agents.base.BaseAgent.__init__" , return_value = None ):
119131 agent = GroundingAgent .__new__ (GroundingAgent )
132+ # BaseAgent attrs
133+ agent ._name = "TestAgent"
120134 agent ._backend_scope = ["gui" , "shell" ]
135+ agent ._grounding_client = MagicMock ()
136+ agent ._llm_client = MagicMock ()
137+ agent ._recording_manager = None
138+ agent ._step = 0
139+ agent ._status = "active"
140+ # GroundingAgent attrs
121141 agent ._system_prompt = "test"
122142 agent ._max_iterations = 5
123143 agent ._visual_analysis_timeout = 10.0
@@ -127,12 +147,18 @@ def _make_agent(self):
127147 agent ._active_skill_ids = []
128148 agent ._skill_registry = None
129149 agent ._last_tools = []
130- agent ._grounding_client = MagicMock ()
131- agent ._llm_client = MagicMock ()
132- agent ._recording_manager = None
133- agent ._name = "TestAgent"
134150 return agent
135151
152+ def test_make_agent_covers_all_init_attrs (self ):
153+ from openspace .agents .grounding_agent import GroundingAgent
154+
155+ source = inspect .getsource (GroundingAgent .__init__ )
156+ init_attrs = {m .group (1 ) for m in re .finditer (r"self\.(_\w+)\s*=" , source )}
157+ agent = self ._make_agent ()
158+ instance_attrs = set (vars (agent ))
159+ missing = init_attrs - instance_attrs
160+ assert not missing , f"_make_agent() missing attrs from __init__: { missing } "
161+
136162 def test_set_skill_context_delegates (self ):
137163 agent = self ._make_agent ()
138164 agent .set_skill_context ("ctx" , ["s1" ])
@@ -168,10 +194,12 @@ def test_cap_message_content_delegates(self):
168194
169195 def test_truncate_messages_delegates (self ):
170196 agent = self ._make_agent ()
171- msgs = [{"role" : "user" , "content" : f"msg{ i } " } for i in range (20 )]
172- result = agent ._truncate_messages (msgs , keep_recent = 3 )
173- # Should have kept system (if any) + recent
174- assert len (result ) <= len (msgs )
197+ # Generate enough messages that truncation kicks in (each ~big content)
198+ msgs = [{"role" : "user" , "content" : "x" * 10_000 } for _ in range (20 )]
199+ result = agent ._truncate_messages (msgs , keep_recent = 3 , max_tokens_estimate = 1000 )
200+ # Must actually truncate — result should be shorter than input
201+ assert len (result ) < len (msgs ), "truncate_messages should reduce message count"
202+ assert len (result ) >= 3 , "should keep at least keep_recent messages"
175203
176204 def test_default_system_prompt_delegates (self ):
177205 agent = self ._make_agent ()
@@ -190,20 +218,158 @@ def test_static_methods_are_callable(self):
190218 """Static method bindings should be callable without self."""
191219 from openspace .agents .grounding_agent import GroundingAgent
192220
193- # _select_key_screenshots
194221 assert callable (GroundingAgent ._select_key_screenshots )
195- # _get_workspace_path
196222 assert callable (GroundingAgent ._get_workspace_path )
197- # _scan_workspace_files
198223 assert callable (GroundingAgent ._scan_workspace_files )
199- # Results statics
200224 assert callable (GroundingAgent ._build_iteration_feedback )
201225 assert callable (GroundingAgent ._remove_previous_guidance )
202226 assert callable (GroundingAgent ._format_tool_executions )
203227 assert callable (GroundingAgent ._check_task_completion )
204228 assert callable (GroundingAgent ._extract_last_assistant_message )
205229
206230
231+ # ── 4b. Async delegation tests ──────────────────────────────────────
232+
233+
234+ class TestAsyncDelegation :
235+ """Async facade methods must delegate to the correct submodule function."""
236+
237+ def _make_agent (self ):
238+ from openspace .agents .grounding_agent import GroundingAgent
239+
240+ with patch ("openspace.agents.base.BaseAgent.__init__" , return_value = None ):
241+ agent = GroundingAgent .__new__ (GroundingAgent )
242+ # BaseAgent attrs
243+ agent ._name = "TestAgent"
244+ agent ._backend_scope = ["gui" , "shell" ]
245+ agent ._grounding_client = MagicMock ()
246+ agent ._llm_client = MagicMock ()
247+ agent ._recording_manager = None
248+ agent ._step = 0
249+ agent ._status = "active"
250+ # GroundingAgent attrs
251+ agent ._system_prompt = "test"
252+ agent ._max_iterations = 5
253+ agent ._visual_analysis_timeout = 10.0
254+ agent ._tool_retrieval_llm = None
255+ agent ._visual_analysis_model = None
256+ agent ._skill_context = None
257+ agent ._active_skill_ids = []
258+ agent ._skill_registry = None
259+ agent ._last_tools = []
260+ return agent
261+
262+ @pytest .mark .asyncio
263+ async def test_process_delegates_to_execution (self ):
264+ agent = self ._make_agent ()
265+ # Patch the module-level import reference in the facade module
266+ with patch (
267+ "openspace.agents.grounding_agent._process_impl" ,
268+ new_callable = AsyncMock ,
269+ ) as mock_proc :
270+ mock_proc .return_value = {"status" : "success" , "response" : "done" }
271+ result = await agent .process ({"instruction" : "test task" })
272+ mock_proc .assert_called_once_with (agent , {"instruction" : "test task" })
273+ assert result == {"status" : "success" , "response" : "done" }
274+
275+ @pytest .mark .asyncio
276+ async def test_get_available_tools_delegates (self ):
277+ agent = self ._make_agent ()
278+ with patch (
279+ "openspace.agents.grounding_agent._get_available_tools_impl" ,
280+ new_callable = AsyncMock ,
281+ ) as mock_tools :
282+ mock_tools .return_value = [{"name" : "tool1" }]
283+ result = await agent ._get_available_tools ("describe task" )
284+ mock_tools .assert_called_once_with (agent , "describe task" )
285+ assert result == [{"name" : "tool1" }]
286+
287+ @pytest .mark .asyncio
288+ async def test_load_all_tools_delegates (self ):
289+ agent = self ._make_agent ()
290+ mock_gc = MagicMock ()
291+ with patch (
292+ "openspace.agents.grounding_agent._load_all_tools_impl" ,
293+ new_callable = AsyncMock ,
294+ ) as mock_load :
295+ mock_load .return_value = [{"name" : "fallback_tool" }]
296+ result = await agent ._load_all_tools (mock_gc )
297+ mock_load .assert_called_once_with (agent , mock_gc )
298+ assert result == [{"name" : "fallback_tool" }]
299+
300+ @pytest .mark .asyncio
301+ async def test_visual_analysis_callback_delegates (self ):
302+ agent = self ._make_agent ()
303+ fake_result = MagicMock ()
304+ with patch (
305+ "openspace.agents.grounding_agent._visual_analysis_callback_impl" ,
306+ new_callable = AsyncMock ,
307+ ) as mock_cb :
308+ mock_cb .return_value = fake_result
309+ result = await agent ._visual_analysis_callback (fake_result , "click" , {}, "gui" )
310+ mock_cb .assert_called_once_with (agent , fake_result , "click" , {}, "gui" )
311+
312+ @pytest .mark .asyncio
313+ async def test_enhance_result_with_visual_context_delegates (self ):
314+ agent = self ._make_agent ()
315+ fake_result = MagicMock ()
316+ with patch (
317+ "openspace.agents.grounding_agent._enhance_result_with_visual_context_impl" ,
318+ new_callable = AsyncMock ,
319+ ) as mock_enh :
320+ mock_enh .return_value = fake_result
321+ result = await agent ._enhance_result_with_visual_context (fake_result , "screenshot" )
322+ mock_enh .assert_called_once_with (agent , fake_result , "screenshot" )
323+
324+ @pytest .mark .asyncio
325+ async def test_check_workspace_artifacts_delegates (self ):
326+ agent = self ._make_agent ()
327+ with patch (
328+ "openspace.agents.grounding_agent._check_workspace_artifacts_impl" ,
329+ new_callable = AsyncMock ,
330+ ) as mock_ws :
331+ mock_ws .return_value = {"artifacts" : []}
332+ result = await agent ._check_workspace_artifacts ({"instruction" : "test" })
333+ mock_ws .assert_called_once_with (agent , {"instruction" : "test" })
334+ assert result == {"artifacts" : []}
335+
336+ @pytest .mark .asyncio
337+ async def test_generate_final_summary_delegates (self ):
338+ agent = self ._make_agent ()
339+ with patch (
340+ "openspace.agents.grounding_agent._generate_final_summary_impl" ,
341+ new_callable = AsyncMock ,
342+ ) as mock_gen :
343+ mock_gen .return_value = ("summary text" , True , [])
344+ result = await agent ._generate_final_summary ("do thing" , [], 3 )
345+ mock_gen .assert_called_once_with (agent , "do thing" , [], 3 )
346+ assert result == ("summary text" , True , [])
347+
348+ @pytest .mark .asyncio
349+ async def test_build_final_result_delegates (self ):
350+ agent = self ._make_agent ()
351+ with patch (
352+ "openspace.agents.grounding_agent._build_final_result_impl" ,
353+ new_callable = AsyncMock ,
354+ ) as mock_build :
355+ mock_build .return_value = {"status" : "complete" }
356+ result = await agent ._build_final_result ("inst" , [], [], 2 , 5 )
357+ mock_build .assert_called_once_with (
358+ agent , "inst" , [], [], 2 , 5 , None , None , None
359+ )
360+ assert result == {"status" : "complete" }
361+
362+ @pytest .mark .asyncio
363+ async def test_record_agent_execution_delegates (self ):
364+ agent = self ._make_agent ()
365+ with patch (
366+ "openspace.agents.grounding_agent._record_agent_execution_impl" ,
367+ new_callable = AsyncMock ,
368+ ) as mock_rec :
369+ await agent ._record_agent_execution ({"status" : "ok" }, "test task" )
370+ mock_rec .assert_called_once_with (agent , {"status" : "ok" }, "test task" )
371+
372+
207373# ── 5. Facade line-count guard ───────────────────────────────────────
208374
209375
0 commit comments