test(web): repair negative security witnesses

This commit is contained in:
Alexandre Teixeira
2026-10-03 03:46:52 +01:00
parent decfab12f9
commit a52c657150
+43 -31
View File
@@ -29,7 +29,8 @@ def _collect(gen):
def _delta_chunk(text): def _delta_chunk(text):
payload = {"choices": [{"delta": {"content": text}}]} # stream_llm_with_fallback exposes normalized SSE, not provider wire JSON.
payload = {"delta": text}
return f"data: {json.dumps(payload)}\n\n" return f"data: {json.dumps(payload)}\n\n"
@@ -57,7 +58,7 @@ def _run_turn(monkeypatch, messages, **kwargs):
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False) monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
_collect( chunks = _collect(
al.stream_agent_loop( al.stream_agent_loop(
"http://local.test/v1", "http://local.test/v1",
"moonshotai/kimi-k3", "moonshotai/kimi-k3",
@@ -68,7 +69,7 @@ def _run_turn(monkeypatch, messages, **kwargs):
**kwargs, **kwargs,
) )
) )
return offered return offered, chunks
@@ -100,60 +101,71 @@ def _contract(offered=("ask_user", "update_plan", "manage_notes"),
# failure this guards is a model that obeys the wording while the runtime # failure this guards is a model that obeys the wording while the runtime
# contradicted it by offering the tool anyway. # contradicted it by offering the tool anyway.
# #
# SCOPE, and it matters: these cover the inferred path, where the turn has no # These cover inferred intent with no explicit web toggle or supplied contract.
# explicit web toggle and the runtime decides from intent. Measured on # On the frozen Wave 3 base all three negatives are recognized. Two are still
# lab@c499c01b, detection there is partial: "don't search online" suppresses # misclassified as web-dependent turns and short-circuit to "web disabled",
# the intent and the web tools are withheld; "Do not search the web" and "No # preventing the requested answer from memory. Only that precise failure is
# web search please" do not, and the tools are offered. # expected below; unrelated exceptions must fail normally.
#
# When the user explicitly enables web for the turn, wording does not withhold
# anything: confirmed end to end against a local Qwen3.5-9B Q4_K_M, where all
# three phrasings were offered web_search, web_fetch and private_browser. That
# may well be correct, an explicit toggle beating an inferred negative, so it
# is recorded here rather than asserted either way.
#
# The two inferred-path cases that do not hold are xfail(strict=True): they
# document the target, run on every suite, and fail the moment the behaviour
# lands. Delete the marker then.
HELD = ["Answer from memory only, don't search online."] HELD = ["Answer from memory only, don't search online."]
NOT_HELD_YET = [ BLOCKED_MEMORY_ONLY = [
"Summarise what you already know. Do not search the web.", "Summarise what you already know. Do not search the web.",
"No web search please, just tell me what you know about Python decorators.", "No web search please, just tell me what you know about Python decorators.",
] ]
@pytest.mark.parametrize("phrasing", HELD) class _MemoryOnlyTurnBlocked(AssertionError):
def test_negative_web_wording_withholds_the_web_tools(monkeypatch, phrasing): """The real loop blocked a memory-only answer as requiring web access."""
offered = _run_turn(monkeypatch, [{"role": "user", "content": phrasing}])
def _assert_negative_web_turn(monkeypatch, phrasing):
offered, chunks = _run_turn(monkeypatch, [{"role": "user", "content": phrasing}])
events = [json.loads(chunk[6:]) for chunk in chunks
if chunk.startswith("data: ") and chunk.strip() != "data: [DONE]"]
finals = [event for event in events if event.get("type") == "final_response"]
if not offered and finals == [{
"type": "final_response",
"content": "Web access is disabled for this turn. Enable web search and resend the request.",
}]:
raise _MemoryOnlyTurnBlocked(
f"Memory-only turn was blocked before any model call: {phrasing!r}; "
f"actual response: {finals[0]['content']}"
)
assert len(offered) == 1, f"Expected one memory-only model call; events: {events!r}"
names = _schema_names(offered[0]) names = _schema_names(offered[0])
assert "web_search" not in names, f"web_search offered despite: {phrasing!r}" assert "web_search" not in names, f"web_search offered despite: {phrasing!r}"
assert "web_fetch" not in names, f"web_fetch offered despite: {phrasing!r}" assert "web_fetch" not in names, f"web_fetch offered despite: {phrasing!r}"
assert any(event.get("delta") == "ok" for event in events), events
assert chunks[-1] == "data: [DONE]\n\n"
@pytest.mark.parametrize("phrasing", HELD)
def test_negative_web_wording_withholds_the_web_tools(monkeypatch, phrasing):
_assert_negative_web_turn(monkeypatch, phrasing)
@pytest.mark.xfail( @pytest.mark.xfail(
strict=True, strict=True,
reason="negative web wording is only partially detected on lab@c499c01b; " raises=_MemoryOnlyTurnBlocked,
"these phrasings still get the web tools offered", reason="negative web wording is recognized but the memory-only turn is "
"misclassified as web-dependent and blocked before the model call; "
"production intent/short-circuit correction is outside test isolation",
) )
@pytest.mark.parametrize("phrasing", NOT_HELD_YET) @pytest.mark.parametrize("phrasing", BLOCKED_MEMORY_ONLY)
def test_negative_web_wording_withholds_the_web_tools_unhandled(monkeypatch, phrasing): def test_negative_web_wording_withholds_the_web_tools_unhandled(monkeypatch, phrasing):
offered = _run_turn(monkeypatch, [{"role": "user", "content": phrasing}]) _assert_negative_web_turn(monkeypatch, phrasing)
names = _schema_names(offered[0])
assert "web_search" not in names, f"web_search offered despite: {phrasing!r}"
assert "web_fetch" not in names, f"web_fetch offered despite: {phrasing!r}"
def test_plain_web_request_still_offers_search(monkeypatch): def test_plain_web_request_still_offers_search(monkeypatch):
"""The guard above must not become a blanket removal of the web tools.""" """The guard above must not become a blanket removal of the web tools."""
offered = _run_turn( offered, chunks = _run_turn(
monkeypatch, monkeypatch,
[{"role": "user", "content": "Search the web for the latest Python release."}], [{"role": "user", "content": "Search the web for the latest Python release."}],
) )
assert len(offered) == 1, chunks
assert "web_search" in _schema_names(offered[0]) assert "web_search" in _schema_names(offered[0])