Skip to content

Commit e69c11c

Browse files
committed
test: harden the suite against tests that cannot fail
A mutation audit of all 120 tests this branch adds — each one probed by breaking the source it covers and re-running — found fifteen that passed against the very regression they named. Every fix below was verified by re-running the mutation that exposed it. One defect shape dominated: an assertion loose enough to admit the broken value. `pytest.raises(BaseException)` also catches the TimeoutError from a hang. `all(size <= 101)` is satisfied by the -1 that an unbounded read() records. `pytest.raises(RequestError)` accepts every JSON-RPC error, including the -32603 that one of these tests exists specifically to rule out. `assert not any(...)` is satisfied by a handler that never ran. Four tests hung rather than failed. A busy-turn regression wedged the suite indefinitely; two subprocess prompts did the same; a slash command that stopped reaching the host loop blocked forever. There is no pytest-timeout plugin, so each of those stalls CI instead of reporting — and a hung job reads as "still running", not "broken". All four are now bounded and fail in seconds. Coverage gaps closed: the protection guard had no negative control, so deleting its opt-in — which turns a warning into a hard error for every ordinary skill — left all 23 tests green. The tool_call_timeout regression covered the new factory but not create_from_server, where the bug actually lived. Transport validation, header copying, the legacy TOML guard, the env-override branch, registry sorting, duplicate rejection and case-insensitive lookup had no coverage at all. Adapters are now closed by an autouse fixture. Forty-five were built and thirty-eight closed, every close a last statement, so a failing assertion abandoned the pump task, agent and SQLite handle on the shared loop — one failure was observed cascading into unrelated failures later. Rebased onto main, which had moved 48 commits. RELEASING.md took main's rewritten text with the ACP facts re-applied, and picked up two package counts main still had at four; make_release.py kept both smoke checks. Signed-off-by: Paul Furgale <pfurgale@nvidia.com>
1 parent d3de552 commit e69c11c

10 files changed

Lines changed: 371 additions & 12 deletions

packages/nooa-acp/tests/test_event_bridge.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,19 @@ async def test_bridge_omits_usage_when_context_window_is_unknown(tmp_path):
216216
await bridge.close()
217217
await agent.close()
218218

219+
# Paired positive: the same event with a known context window must emit a
220+
# UsageUpdate. Without this, `return` at the top of _on_llm_complete passes
221+
# both halves — an AgentMessageChunk control comes from a different handler
222+
# and cannot tell "the guard works" from "usage never fires".
223+
sized = CodingAgent(llm=FakeLLMClient(), cwd=tmp_path)
224+
sized_client = _RecordingClient()
225+
sized_bridge = ACPEventBridge(sized, sized_client, "session-2") # type: ignore[arg-type]
226+
sized.event_manager.add(LLMComplete(prompt_tokens=40, completion_tokens=10, cost_usd=0.25))
227+
await sized_bridge.flush()
228+
assert any(isinstance(update, UsageUpdate) for _, update in sized_client.updates)
229+
await sized_bridge.close()
230+
await sized.close()
231+
219232

220233
async def test_bridge_emits_structured_file_edit(tmp_path):
221234
agent = CodingAgent(llm=FakeLLMClient(), cwd=tmp_path)
@@ -389,6 +402,9 @@ async def test_a_cancelled_command_reads_as_cancellation_not_a_crash(tmp_path):
389402
rendered = str(finished)
390403
assert "Cancelled by user." in rendered
391404
assert "CancelledError" not in rendered
405+
# Status too: dropping `or event.cancelled` renders a cancelled command as a
406+
# green completed card while the reason text still reads correctly.
407+
assert finished.status == "failed"
392408
await bridge.close()
393409

394410

@@ -417,7 +433,7 @@ async def test_bare_expression_result_is_shown_not_reported_as_no_output(tmp_pat
417433
await bridge.flush()
418434

419435
rendered = "".join(str(update) for _, update in client.updates)
420-
assert "42" in rendered
436+
assert "Out[3]: 42" in rendered, rendered
421437
assert "Completed." not in rendered
422438
await bridge.close()
423439

@@ -461,6 +477,15 @@ async def test_an_unfinished_tool_call_does_not_leak_for_the_session(tmp_path):
461477
await bridge.close()
462478
assert bridge._open_tools == set()
463479
assert bridge._python_source == {}
480+
# And the card was actually closed out for the client: clearing the private
481+
# state alone leaves it spinning, which is what the docstring forbids.
482+
closing = [
483+
update
484+
for _, update in client.updates
485+
if isinstance(update, ToolCallProgress) and update.tool_call_id == "t3"
486+
]
487+
assert closing and closing[-1].status == "failed", client.updates
488+
assert closing[-1].title == "Unfinished"
464489

465490

466491
async def test_a_cancelled_tool_card_is_titled_cancelled(tmp_path):

packages/nooa-acp/tests/test_protocol.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
from pathlib import Path
88

9+
import pytest
910
from acp import PROTOCOL_VERSION, spawn_agent_process, text_block
1011
from acp.connection import StreamDirection
1112
from acp.schema import (
@@ -88,7 +89,10 @@ async def test_acp_subprocess_transcript(tmp_path, monkeypatch):
8889
initialized = await connection.initialize(PROTOCOL_VERSION)
8990
session = await connection.new_session(str(tmp_path))
9091
await asyncio.wait_for(client.commands_updated.wait(), timeout=5)
91-
response = await connection.prompt(session.session_id, [text_block("run smoke test")])
92+
response = await asyncio.wait_for(
93+
connection.prompt(session.session_id, [text_block("run smoke test")]),
94+
timeout=_HANG_TIMEOUT,
95+
)
9296

9397
assert initialized.agent_info is not None
9498
assert initialized.agent_info.name == "nooa-acp"
@@ -156,9 +160,12 @@ async def test_acp_subprocess_dispatches_advertised_slash_command(tmp_path, monk
156160
) as (connection, _process):
157161
await connection.initialize(PROTOCOL_VERSION)
158162
session = await connection.new_session(str(tmp_path))
159-
response = await connection.prompt(
160-
session.session_id,
161-
[text_block("/protocol-check ready")],
163+
response = await asyncio.wait_for(
164+
connection.prompt(
165+
session.session_id,
166+
[text_block("/protocol-check ready")],
167+
),
168+
timeout=_HANG_TIMEOUT,
162169
)
163170

164171
assert response.stop_reason == "end_turn"
@@ -219,6 +226,14 @@ async def test_acp_subprocess_closes_a_session_over_the_wire(tmp_path):
219226
session = await connection.new_session(str(tmp_path))
220227
await connection.close_session(session.session_id)
221228

229+
# Routable is only half of it: a no-op handler leaks the runtime for the
230+
# process lifetime. Prompting a closed session must now be rejected.
231+
with pytest.raises(Exception, match="(?i)not found|no such|unknown"):
232+
await asyncio.wait_for(
233+
connection.prompt(session.session_id, [text_block("still there?")]),
234+
timeout=_HANG_TIMEOUT,
235+
)
236+
222237
capabilities = initialized.agent_capabilities.session_capabilities
223238
assert capabilities is not None and capabilities.close is not None
224239

packages/nooa-acp/tests/test_runtime.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
SessionRuntimePool,
1515
)
1616

17+
# Bounds a hang, not the expected duration.
18+
_HANG_TIMEOUT = 30
19+
1720

1821
class _RuntimeValue:
1922
def __init__(self) -> None:
@@ -25,11 +28,17 @@ async def close(self) -> None:
2528

2629
async def test_same_session_rejects_a_second_foreground_turn():
2730
runtime = SessionRuntime("one", object())
31+
32+
async def _claim_again() -> None:
33+
async with runtime.turn():
34+
pass
35+
2836
async with runtime.turn():
2937
assert runtime.busy is True
38+
# Bounded: if turns start queueing instead of failing fast — the exact
39+
# regression — this wedges on the inner lock and hangs the suite.
3040
with pytest.raises(SessionBusyError):
31-
async with runtime.turn():
32-
pass
41+
await asyncio.wait_for(_claim_again(), timeout=_HANG_TIMEOUT)
3342

3443

3544
async def test_simultaneous_turn_claims_do_not_queue():
@@ -95,7 +104,10 @@ async def active_turn() -> None:
95104
turn_task = asyncio.create_task(active_turn())
96105
await turn_started.wait()
97106
close_task = asyncio.create_task(runtime.close())
98-
await asyncio.sleep(0)
107+
# A single yield is satisfied by scheduling latency — _close_once has not
108+
# even started — so it passes with the turn lock removed entirely.
109+
for _ in range(20):
110+
await asyncio.sleep(0)
99111
assert close_task.done() is False
100112
assert value.close_calls == 0
101113
release_turn.set()

packages/nooa-acp/tests/test_server.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import sys
88
import threading
9+
from contextlib import suppress
910
from typing import Literal
1011
from unittest.mock import AsyncMock, call, patch
1112

@@ -54,6 +55,37 @@ def isolated_user_config(tmp_path_factory, monkeypatch):
5455
return home
5556

5657

58+
# JSON-RPC code for resource_not_found; asserting it distinguishes a typed
59+
# protocol error from the generic -32603 internal_error.
60+
_RESOURCE_NOT_FOUND = -32002
61+
62+
63+
@pytest.fixture(autouse=True)
64+
async def close_every_adapter(monkeypatch):
65+
"""Guarantee teardown for every adapter a test builds.
66+
67+
Each test closes its adapter as the last statement, so a failing assertion
68+
leaks the pump task, bootstrap task, agent, MCP tools and SQLite handle onto
69+
the shared event loop for the rest of the module — which has been observed
70+
turning one failure into unrelated intermittent failures later. Closing is
71+
idempotent, so tests keep their explicit close and this only covers the
72+
paths that do not reach it.
73+
"""
74+
built: list[CodingACPAdapter] = []
75+
original = CodingACPAdapter.__init__
76+
77+
def tracking_init(self, *args, **kwargs):
78+
original(self, *args, **kwargs)
79+
built.append(self)
80+
81+
monkeypatch.setattr(CodingACPAdapter, "__init__", tracking_init)
82+
yield built
83+
84+
for adapter in built:
85+
with suppress(Exception):
86+
await adapter.close()
87+
88+
5789
def _completed_llm() -> FakeLLMClient:
5890
return FakeLLMClient.with_tool_call(
5991
"execute_python",
@@ -1267,8 +1299,11 @@ async def test_prompt_on_a_closing_session_is_a_clean_protocol_error(tmp_path):
12671299
runtime = await adapter._sessions.get(session.session_id)
12681300
await runtime.close()
12691301

1270-
with pytest.raises(RequestError):
1302+
# The code, not merely "some RequestError": a bare raises() accepts the
1303+
# -32603 internal_error this test exists to rule out.
1304+
with pytest.raises(RequestError) as raised:
12711305
await adapter.prompt(session.session_id, [text_block("do the work")])
1306+
assert raised.value.code == _RESOURCE_NOT_FOUND, raised.value.code
12721307
await adapter.close()
12731308

12741309

packages/nooa-cli/tests/test_coding_activity.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""Semantic activity emitted by the shared interactive coding tools."""
44

55
import asyncio
6+
from pathlib import Path
67

78
import nooa_cli.coding.activity as activity
89
import pytest
@@ -314,3 +315,56 @@ async def test_overwriting_an_empty_file_reports_no_original_lines(tmp_path):
314315
edit = next(event for event in events if isinstance(event, FileEdit))
315316
assert edit.operation == "update"
316317
assert (edit.start_line, edit.end_line) == (None, None)
318+
319+
320+
async def test_a_path_outside_the_workspace_is_made_relative(tmp_path):
321+
"""The fallback branch was unreachable from the other tests.
322+
323+
Every fixture file lives under tmp_path, which is the shell cwd, so
324+
relative_to() always succeeds and the except branch never ran — leaving
325+
`assert "a//" not in edit.diff` unable to fail.
326+
"""
327+
shell, _ = _observed_shell(tmp_path)
328+
try:
329+
assert shell._diff_path(Path("/etc/hosts")) == "etc/hosts"
330+
inside = tmp_path / "kept.txt"
331+
assert shell._diff_path(inside) == "kept.txt"
332+
finally:
333+
await shell.close()
334+
335+
336+
async def test_overwrite_reads_the_previous_content_boundedly(tmp_path):
337+
"""The bound must be on the read, not only on what is emitted.
338+
339+
The other activity tests assert on the emitted event, which is produced by
340+
pformat/TruncatingStringIO *after* the read — so reverting to an unbounded
341+
read_text() of a workspace-controlled file is invisible to them.
342+
"""
343+
reads: list[int] = []
344+
real_open = Path.open
345+
346+
def spying_open(self, *args, **kwargs):
347+
stream = real_open(self, *args, **kwargs)
348+
real_read = stream.read
349+
350+
def read(size=-1):
351+
reads.append(size)
352+
return real_read(size)
353+
354+
stream.read = read # type: ignore[method-assign]
355+
return stream
356+
357+
target = tmp_path / "big.txt"
358+
target.write_text("x" * 5000)
359+
shell, _ = _observed_shell(tmp_path)
360+
try:
361+
with pytest.MonkeyPatch.context() as patcher:
362+
patcher.setattr(Path, "open", spying_open)
363+
await shell.write_file("big.txt", "replacement\n")
364+
finally:
365+
await shell.close()
366+
367+
# Positive sizes only: an unbounded read records -1, which satisfies any
368+
# "<= limit" assertion.
369+
assert reads, "the previous content was never read"
370+
assert all(0 < size <= activity._MAX_DIFF_INPUT_CHARS + 1 for size in reads), reads

packages/nooa-cli/tests/test_coding_agent.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ def read(size=-1):
178178

179179
rendered = instructions.render_agent_instructions(tmp_path)
180180

181-
assert reads and all(size is not None and size <= 101 for size in reads), reads
181+
# Positive sizes only: an unbounded .read() records -1, which satisfies
182+
# any `<= limit` assertion and made this test pass against the very
183+
# regression it names.
184+
assert reads == [101], reads
182185
assert "[... truncated ...]" in rendered
183186
assert len(rendered) < 1_000
184187

packages/nooa-cli/tests/test_coding_settings.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,42 @@ def test_a_non_utf8_settings_file_does_not_abort_discovery(tmp_path, monkeypatch
188188
(config_dir / "settings.yaml").write_bytes(b"\xff\xfe coding:\n")
189189

190190
assert load_coding_skills_dirs(workspace) == [conventional.resolve()]
191+
192+
193+
def test_a_non_utf8_legacy_config_does_not_abort_discovery(tmp_path, monkeypatch):
194+
"""The legacy TOML reader needs the same UnicodeError guard as the YAML one.
195+
196+
The sibling test only feeds a bad settings.yaml, so dropping UnicodeError
197+
from the config.toml handler went unnoticed.
198+
"""
199+
workspace = tmp_path / "workspace"
200+
conventional = workspace / ".agents" / "skills"
201+
conventional.mkdir(parents=True)
202+
monkeypatch.delenv("NEMO_OO_SETTINGS", raising=False)
203+
204+
config_dir = workspace / ".nooa"
205+
config_dir.mkdir()
206+
(config_dir / "config.toml").write_bytes(b"\xff\xfe [tui]\n")
207+
208+
assert load_coding_skills_dirs(workspace) == [conventional.resolve()]
209+
210+
211+
def test_an_env_override_suppresses_a_legacy_only_workspace(tmp_path, monkeypatch):
212+
"""The env half of the legacy guard had no test.
213+
214+
Every existing case also has a modern settings.yaml, so `modern_key_set`
215+
short-circuits and the NEMO_OO_SETTINGS check is never exercised.
216+
"""
217+
workspace = tmp_path / "workspace"
218+
legacy = tmp_path / "legacy-skills"
219+
override = tmp_path / "override.yaml"
220+
workspace.mkdir()
221+
legacy.mkdir()
222+
override.write_text("coding:\n additional_skills_dirs: []\n")
223+
monkeypatch.setenv("NEMO_OO_SETTINGS", str(override))
224+
225+
config_dir = workspace / ".nooa"
226+
config_dir.mkdir()
227+
(config_dir / "config.toml").write_text(f'[tui]\nlibs_dirs = ["{legacy}"]\n')
228+
229+
assert load_coding_skills_dirs(workspace) == []

packages/nooa-cli/tests/test_coding_slash_commands.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,57 @@ async def wait(self) -> str:
121121
registry = CodingSlashCommandRegistry(agent)
122122
try:
123123
invocation = asyncio.create_task(registry.invoke("wait", ""))
124-
await started.wait()
124+
# Bounded: a regression that stops the coroutine reaching the host loop
125+
# would otherwise wedge here forever, so CI stalls instead of going red.
126+
await asyncio.wait_for(started.wait(), timeout=30)
125127
invocation.cancel()
126128

127129
with pytest.raises(asyncio.CancelledError):
128-
await invocation
130+
await asyncio.wait_for(invocation, timeout=30)
131+
finally:
132+
registry.close()
133+
await agent.close()
134+
135+
136+
async def test_commands_are_sorted_deduplicated_and_case_insensitive(tmp_path):
137+
"""Three registry behaviours that no test exercised.
138+
139+
Every existing case registers one command and reads it back in canonical
140+
case, so unsorted output, a dropped duplicate guard, and lost case
141+
normalisation were all invisible.
142+
"""
143+
144+
class _Beta(Skill):
145+
@slash_command("beta")
146+
def beta(self, args: str) -> str:
147+
"""Beta."""
148+
return "beta"
149+
150+
class _Alpha(Skill):
151+
@slash_command("alpha")
152+
def alpha(self, args: str) -> str:
153+
"""Alpha."""
154+
return "alpha"
155+
156+
class _AlphaAgain(Skill):
157+
@slash_command("alpha")
158+
def alpha(self, args: str) -> str:
159+
"""Duplicate."""
160+
return "duplicate"
161+
162+
agent = CodingAgent(llm=FakeLLMClient(), cwd=tmp_path)
163+
# Registered out of order, and with a colliding name.
164+
# Registry names sort opposite to the command names they provide, so
165+
# _commands is built in [beta, alpha] order and sorting is observable.
166+
agent.skills.register("test.aaa", _Beta())
167+
agent.skills.register("test.zzz", _Alpha())
168+
agent.skills.register("test.zzzz", _AlphaAgain())
169+
registry = CodingSlashCommandRegistry(agent)
170+
try:
171+
names = [command.name for command in registry.commands()]
172+
assert names == sorted(names), names
173+
assert names.count("alpha") == 1, names
174+
assert registry.get("ALPHA") is not None, "lookup is not case-insensitive"
129175
finally:
130176
registry.close()
131177
await agent.close()

0 commit comments

Comments
 (0)