Skip to content

Commit 444b802

Browse files
Brian KrafftCopilot
andcommitted
fix: address 8eyes-test review findings (P0+P1)
- Fix circular import tests: pop entire grounding subtree, not just target module - Add 9 async delegation tests proving facade->submodule wiring (process, tools, visual, workspace, results, recording) - Add _make_agent drift guard (auto-detects attrs missing from __init__) - Add identity assertion for backward-compat import paths (is, not just isclass) - Strengthen truncate_messages test with token-exceeding inputs - Add BaseAgent._step/_status to test fixtures - 43 integration tests, 1765 total passed, 0 failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 010607b commit 444b802

1 file changed

Lines changed: 184 additions & 18 deletions

File tree

tests/test_grounding_p5b_integration.py

Lines changed: 184 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import importlib
1313
import inspect
14+
import re
1415
import sys
1516
from types import ModuleType
1617
from typing import Any, Dict, List, Optional
@@ -63,24 +64,27 @@ def test_eight_submodules_exist(self):
6364

6465

6566
class 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

8286
class 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

Comments
 (0)