Skip to content

Commit 030cb70

Browse files
kingpanther13claude
andcommitted
refactor(c901): UAT harness below C901 threshold, remove from grandfather list
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB
1 parent cdbd40a commit 030cb70

5 files changed

Lines changed: 1065 additions & 614 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,6 @@ ignore = [
174174
"src/ha_mcp/tools/tools_dev.py" = ["C901"]
175175
"src/ha_mcp/tools/tools_system.py" = ["C901"]
176176
"tests/src/unit/test_advanced_settings_coverage.py" = ["C901"]
177-
"tests/uat/ha_wait.py" = ["C901"]
178-
"tests/uat/openai_agent.py" = ["C901"]
179-
"tests/uat/run_uat.py" = ["C901"]
180-
"tests/uat/stories/run_story.py" = ["C901"]
181177

182178
[tool.pytest.ini_options]
183179
testpaths = ["tests"]

tests/uat/ha_wait.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,8 @@
2121
ENTITY_TIMEOUT = 30
2222

2323

24-
def wait_for_ha_ready(url: str, token: str) -> None:
25-
"""Wait until HA is fully ready: components loaded, entities registered.
26-
27-
Raises TimeoutError if any gate is not reached within its timeout.
28-
"""
29-
headers = {"Authorization": f"Bearer {token}"}
30-
31-
# Gate 1: API reachable and components loaded
24+
def _wait_for_components(url: str, headers: dict[str, str]) -> None:
25+
"""Gate 1: block until the HA API responds and enough components load."""
3226
logger.info(f"Waiting for HA at {url} ...")
3327
api_responded = False
3428
last_component_count = 0
@@ -62,7 +56,9 @@ def wait_for_ha_ready(url: str, token: str) -> None:
6256
f"Only {last_component_count} components loaded (minimum: {MIN_COMPONENTS})."
6357
)
6458

65-
# Gate 2: Entities registered
59+
60+
def _wait_for_entities(url: str, headers: dict[str, str]) -> None:
61+
"""Gate 2: block until enough entities have registered."""
6662
logger.info("Waiting for HA entities to register...")
6763
last_entity_count = 0
6864
for attempt in range(ENTITY_TIMEOUT):
@@ -88,3 +84,17 @@ def wait_for_ha_ready(url: str, token: str) -> None:
8884
f"Entity registration timed out after {ENTITY_TIMEOUT}s. "
8985
f"Only {last_entity_count} entities registered (minimum: {MIN_ENTITIES})."
9086
)
87+
88+
89+
def wait_for_ha_ready(url: str, token: str) -> None:
90+
"""Wait until HA is fully ready: components loaded, entities registered.
91+
92+
Raises TimeoutError if any gate is not reached within its timeout.
93+
"""
94+
headers = {"Authorization": f"Bearer {token}"}
95+
96+
# Gate 1: API reachable and components loaded
97+
_wait_for_components(url, headers)
98+
99+
# Gate 2: Entities registered
100+
_wait_for_entities(url, headers)

tests/uat/openai_agent.py

Lines changed: 114 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,108 @@ def extract_tool_result_text(result) -> str:
213213
return str(result)
214214

215215

216+
def _maybe_warn_no_think(
217+
no_think: bool,
218+
no_think_warned: bool,
219+
message,
220+
reasoning_this_turn: int,
221+
model: str,
222+
) -> bool:
223+
"""Warn once if --no-think was requested but the model still reasoned.
224+
225+
Returns the updated ``no_think_warned`` flag.
226+
"""
227+
# If --no-think was requested but the model still reasoned, the backend
228+
# didn't honor it (e.g. LM Studio can't map enable_thinking to some
229+
# Qwen3.6 GGUFs). Warn once so the no-op is visible instead of silently
230+
# paying full reasoning-decode cost. reasoning_tokens is the structured
231+
# signal; reasoning_content and an inline <think> block in content cover
232+
# servers that emit reasoning without a separate token detail.
233+
if no_think and not no_think_warned:
234+
still_reasoning = (
235+
reasoning_this_turn
236+
or getattr(message, "reasoning_content", None)
237+
or "<think>" in (message.content or "").lower()
238+
)
239+
if still_reasoning:
240+
detail = (
241+
f"{reasoning_this_turn} reasoning tokens"
242+
if reasoning_this_turn
243+
else "reasoning in output, token count unavailable"
244+
)
245+
logger.warning(
246+
"--no-think requested but model %s still produced reasoning "
247+
"(%s); backend may not honor enable_thinking",
248+
model,
249+
detail,
250+
)
251+
no_think_warned = True
252+
return no_think_warned
253+
254+
255+
async def _dispatch_tool_calls(
256+
message,
257+
messages: list[dict],
258+
mcp_client: MCPClient,
259+
tool_trace_sink: list[str] | None,
260+
total_calls: int,
261+
total_success: int,
262+
total_fail: int,
263+
) -> tuple[int, int, int]:
264+
"""Execute one turn's tool calls, appending each result to ``messages``.
265+
266+
Returns the updated ``(total_calls, total_success, total_fail)`` counters.
267+
"""
268+
for tc in message.tool_calls:
269+
total_calls += 1
270+
tool_name = tc.function.name
271+
try:
272+
tool_args = json.loads(tc.function.arguments)
273+
except json.JSONDecodeError as e:
274+
malformed_line = (
275+
f" [tool] {tool_name}: malformed arguments: {tc.function.arguments!r}"
276+
)
277+
logger.info(malformed_line)
278+
if tool_trace_sink is not None:
279+
tool_trace_sink.append(malformed_line.strip())
280+
total_fail += 1
281+
messages.append(
282+
{
283+
"role": "tool",
284+
"tool_call_id": tc.id,
285+
"content": f"Error: Invalid JSON in tool arguments: {e}",
286+
}
287+
)
288+
continue
289+
290+
call_line = f" [tool] {tool_name}({tool_args})"
291+
logger.info(call_line)
292+
if tool_trace_sink is not None:
293+
tool_trace_sink.append(call_line.strip())
294+
295+
try:
296+
result = await mcp_client.call_tool(tool_name, tool_args)
297+
result_text = extract_tool_result_text(result)
298+
total_success += 1
299+
except Exception as e:
300+
err_text = _strip_pydantic_url(str(e))
301+
result_text = f"Error: {err_text}"
302+
total_fail += 1
303+
# Server-side WARNING log already shows the failure details;
304+
# only record to the trace sink for test artifacts.
305+
if tool_trace_sink is not None:
306+
tool_trace_sink.append(f"[tool] {tool_name} failed: {err_text}")
307+
308+
messages.append(
309+
{
310+
"role": "tool",
311+
"tool_call_id": tc.id,
312+
"content": result_text,
313+
}
314+
)
315+
return total_calls, total_success, total_fail
316+
317+
216318
async def tool_call_loop(
217319
client: openai.AsyncOpenAI,
218320
model: str,
@@ -292,31 +394,9 @@ async def tool_call_loop(
292394
choice = response.choices[0]
293395
message = choice.message
294396

295-
# If --no-think was requested but the model still reasoned, the backend
296-
# didn't honor it (e.g. LM Studio can't map enable_thinking to some
297-
# Qwen3.6 GGUFs). Warn once so the no-op is visible instead of silently
298-
# paying full reasoning-decode cost. reasoning_tokens is the structured
299-
# signal; reasoning_content and an inline <think> block in content cover
300-
# servers that emit reasoning without a separate token detail.
301-
if no_think and not no_think_warned:
302-
still_reasoning = (
303-
reasoning_this_turn
304-
or getattr(message, "reasoning_content", None)
305-
or "<think>" in (message.content or "").lower()
306-
)
307-
if still_reasoning:
308-
detail = (
309-
f"{reasoning_this_turn} reasoning tokens"
310-
if reasoning_this_turn
311-
else "reasoning in output, token count unavailable"
312-
)
313-
logger.warning(
314-
"--no-think requested but model %s still produced reasoning "
315-
"(%s); backend may not honor enable_thinking",
316-
model,
317-
detail,
318-
)
319-
no_think_warned = True
397+
no_think_warned = _maybe_warn_no_think(
398+
no_think, no_think_warned, message, reasoning_this_turn, model
399+
)
320400

321401
# No tool calls — we have a final response
322402
if not message.tool_calls:
@@ -354,54 +434,15 @@ async def tool_call_loop(
354434
}
355435
)
356436

357-
for tc in message.tool_calls:
358-
total_calls += 1
359-
tool_name = tc.function.name
360-
try:
361-
tool_args = json.loads(tc.function.arguments)
362-
except json.JSONDecodeError as e:
363-
malformed_line = (
364-
f" [tool] {tool_name}: malformed arguments: "
365-
f"{tc.function.arguments!r}"
366-
)
367-
logger.info(malformed_line)
368-
if tool_trace_sink is not None:
369-
tool_trace_sink.append(malformed_line.strip())
370-
total_fail += 1
371-
messages.append(
372-
{
373-
"role": "tool",
374-
"tool_call_id": tc.id,
375-
"content": f"Error: Invalid JSON in tool arguments: {e}",
376-
}
377-
)
378-
continue
379-
380-
call_line = f" [tool] {tool_name}({tool_args})"
381-
logger.info(call_line)
382-
if tool_trace_sink is not None:
383-
tool_trace_sink.append(call_line.strip())
384-
385-
try:
386-
result = await mcp_client.call_tool(tool_name, tool_args)
387-
result_text = extract_tool_result_text(result)
388-
total_success += 1
389-
except Exception as e:
390-
err_text = _strip_pydantic_url(str(e))
391-
result_text = f"Error: {err_text}"
392-
total_fail += 1
393-
# Server-side WARNING log already shows the failure details;
394-
# only record to the trace sink for test artifacts.
395-
if tool_trace_sink is not None:
396-
tool_trace_sink.append(f"[tool] {tool_name} failed: {err_text}")
397-
398-
messages.append(
399-
{
400-
"role": "tool",
401-
"tool_call_id": tc.id,
402-
"content": result_text,
403-
}
404-
)
437+
total_calls, total_success, total_fail = await _dispatch_tool_calls(
438+
message,
439+
messages,
440+
mcp_client,
441+
tool_trace_sink,
442+
total_calls,
443+
total_success,
444+
total_fail,
445+
)
405446

406447
# Max iterations reached without a final message. Flag it so callers can
407448
# surface it as a test failure — otherwise a model stuck in a tool-call

0 commit comments

Comments
 (0)