Files
brain-of-reese/tests/e2e/test_tool_scaffolding_guardrails.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

440 lines
18 KiB
Python

"""Phase 71 E2E (Playwright, mock-only): tool-scaffolding guardrails —
the deterministic strip + one bounded recovery, through the real UI.
Owner request (chat, 2026-09-03): the same incident as phase 70 — a
deflected round streamed the model's raw
``<|tool_call_start|>[read(path='…')]<|tool_call_end|>`` chat-template
markup into the UI although no tools were offered. The guardrail is
DETERMINISTIC ONLY (no model in detection or repair): a streaming
filter strips known scaffolding from ``delta.content`` server-side
(``app/rag/scaffolding.py``), and a reply whose visible content ends
up empty gets exactly ONE bounded recovery (``tools=None``, the
harness correction folded into the system prompt); a second empty
reply settles with the dedicated error frame.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_tool_scaffolding_guardrails.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic scaffolding flows in ``tests/e2e/mock_llm.py`` (phase
71), which are independent of the ``<tools>`` marker:
* ``emit raw tool markup`` (``SCAFFOLD_TRIGGER``): request 1 streams
ONLY the incident span as ``delta.content`` (split across the
mock's 12-char chunks — the filter's boundary path), no structured
``tool_calls``, no reasoning; the follow-up request carrying
``CORRECTION_INSTRUCTION`` in the system prompt (the one bounded
recovery) streams the clean ``SCAFFOLD_RECOVERY_ANSWER``.
* ``always emit raw tool markup`` (``SCAFFOLD_ALWAYS_TRIGGER``): the
scaffolding-only span on EVERY request (the recovery included) —
the terminal malformed-reply path.
Both triggers run on an EMPTY knowledge base: with zero chunks the
honesty gate is LOW (cosine 0.0 < the E2E 0.30 threshold, no FTS
hits), so every turn takes the DEFLECTED path — the exact path the
2026-09-03 incident hit — where the filter + recovery live in
``app/api/chat.py``.
Test → phase mapping (Playwright Mapping Rule):
1. ``test_recovery_strips_scaffolding_and_streams_clean_answer`` — the
recovery case: the turn settles (the composer re-enables, ``done``
on the wire), the final answer bubble carries the recovery's clean
text, ``document.body.innerText`` contains NEITHER
``tool_call_start`` nor ``tool_call_end`` (nor the raw
``[read(path=…`` fragment), and no error banner — and wire-level,
no ``delta`` frame ever carries a scaffolding fragment (the strip
happens server-side, not in the UI).
2. ``test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable``
— the terminal case: the existing error state renders with the
dedicated copy ("The model returned a malformed reply — please try
again."), no raw tokens in the DOM, no answer bubble, no ``done``
and NO ``query_log`` row (the existing terminal-error semantics) —
and the app stays usable: a follow-up plain question in the same
session gets a normal deflected answer and the banner clears.
3. ``test_plain_turn_never_recovers_and_streams_byte_clean`` — no
false positive: a plain deflected question streams its
first-request answer byte-clean (the concatenated delta text is
EXACTLY the mock's deterministic deflection answer — not the
recovery's), with no error state and no recovery request visible
(the turn settles on the first request).
"""
from __future__ import annotations
import json
import re
import time
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.db import SessionLocal
from app.models import QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer
# --------------------------------------------------------------------------
# Questions + the mock's deterministic expectations
# --------------------------------------------------------------------------
#: Carries ``SCAFFOLD_TRIGGER`` (and nothing else — the module-level
#: asserts below pin the trigger exclusions).
RECOVERY_Q = "Please emit raw tool markup in your reply to this."
#: Carries ``SCAFFOLD_ALWAYS_TRIGGER`` — the scaffolding-only span on
#: EVERY request, recovery included (the terminal path).
TERMINAL_Q = "Please always emit raw tool markup in every reply."
#: A plain question (the phase-67 deflection shape): no trigger at all.
PLAIN_Q = "How do I bake sourdough bread?"
for _q in (RECOVERY_Q, TERMINAL_Q, PLAIN_Q):
for _other in (
"read two documents",
"search your documents",
"write a long answer",
"think in paragraphs",
"think out loud",
"show the end of your notes",
"show me a table",
"fail then answer",
"always fail",
"embed fail once",
"pretend to think slowly",
"use your tools",
):
assert _other not in _q.lower(), f"{_other!r} unexpectedly in {_q!r}"
assert "emit raw tool markup" in RECOVERY_Q.lower()
assert "always emit raw tool markup" not in RECOVERY_Q.lower()
assert "always emit raw tool markup" in TERMINAL_Q.lower()
assert "emit raw tool markup" not in PLAIN_Q.lower()
#: The terminal malformed-reply copy (``app.api.chat`` — the phase-71
#: dedicated error frame; the mock's ALWAYS trigger is what makes the
#: recovery come back empty).
MALFORMED_ERROR_COPY = "The model returned a malformed reply — please try again."
#: The mock's deterministic deflection phrase (the phase-67 pin).
DEFLECT_PHRASE = r"haven't done anything like that"
#: The raw scaffolding fragments that must NEVER reach the user — the
#: span's tokens and the incident's argument fragment (``SCAFFOLD_SPAN``
#: in the mock).
RAW_TOKENS = ("tool_call_start", "tool_call_end", "[read(path=", "<|")
def _expected_deflect_answer(question: str) -> str:
"""The mock's deterministic DEFLECT_MODE answer for *question*.
Derived from the mock itself (``compose_answer`` on a synthetic
body: the mode marker in the system prompt, the question as the
user message, no tuning/KB sections — the suite truncates
``steering_notes`` / ``kb_overview``, so the real prompt carries
none), so the byte-clean pin can never drift from the mock.
"""
return compose_answer(
{
"messages": [
{"role": "system", "content": "DEFLECT_MODE marker"},
{"role": "user", "content": question},
]
}
)
# --------------------------------------------------------------------------
# DB reset (EMPTY knowledge base → every turn is deterministically
# deflected: the LOW gate, the incident's path) + query_log reads
# --------------------------------------------------------------------------
def _reset_db_empty() -> None:
"""Truncate the KB (plus the prompt-shaping tables): an EMPTY
knowledge base, so the honesty gate is LOW for every question
(no chunks → cosine 0.0 < 0.30, fts_hits 0) — the deflected path
where the phase-71 filter + recovery live, and the prompts stay
byte-stable regardless of leftovers from other suites."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
def _query_log_rows() -> list[QueryLog]:
with SessionLocal() as db:
return list(db.scalars(select(QueryLog)).all())
# --------------------------------------------------------------------------
# Page hooks (the SSE capture — the house pattern from
# test_agent_document_tools.py / test_llm_retry.py)
# --------------------------------------------------------------------------
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level assertions
#: independent of the UI rendering.
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_sse_hook(page: Page) -> None:
page.evaluate(SSE_HOOK)
def _drain_frames(page: Page, terminal: str = "done") -> list[dict]:
"""One turn's SSE frames: wait for that turn's *terminal* frame
(``done`` — or ``error`` for the terminal case), then return EVERY
frame captured since the last drain. The hook's background read
appends the whole stream at once after it closes, so
clearing-and-reading is race-free per turn: the terminal frame is
observed only in the batch that carries the turn's full frame
sequence."""
deadline = time.monotonic() + 30.0
while True:
raw = page.evaluate(
"() => { const f = window.__sseFrames || []; "
"window.__sseFrames = []; return f; }"
)
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == terminal for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `{terminal}` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _delta_text(frames: list[dict]) -> str:
"""The turn's answer text exactly as the wire carried it."""
return "".join(f.get("text", "") for f in frames if f.get("type") == "delta")
def _assert_no_raw_tokens_on_wire(frames: list[dict]) -> None:
for frame in frames:
if frame.get("type") != "delta":
continue
text = frame.get("text", "")
for raw in RAW_TOKENS:
assert raw not in text, f"raw scaffolding {raw!r} on the wire: {text!r}"
def _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button recovered.
Phase 48: the label assertion carries the settle wait with an
explicit timeout — the in-flight button is the enabled Stop control
(never disabled), so ``to_be_enabled`` no longer blocks until the
turn settles."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def _assert_no_raw_tokens(page: Page) -> None:
"""The user-visible contract: no raw scaffolding fragment anywhere
in the rendered page (``document.body.innerText``)."""
body = page.locator("body").inner_text()
for raw in RAW_TOKENS:
assert raw not in body, f"raw scaffolding {raw!r} leaked into the DOM"
def _assert_no_error_banner(page: Page) -> None:
"""The turn settled through the normal done path — never the red
role=alert error banner (the KB-offline banner is a separate,
health-driven state the db_ready fixture keeps away)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
# --------------------------------------------------------------------------
# 1. The recovery case: the span is stripped server-side, the ONE
# bounded recovery answers, and no raw token ever reaches the DOM
# --------------------------------------------------------------------------
def test_recovery_strips_scaffolding_and_streams_clean_answer(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, RECOVERY_Q)
_wait_settled(page)
# The final answer bubble carries the RECOVERY's clean text — the
# mock's deterministic recovery answer, proof the one bounded
# recovery ran (request 2, with the correction in the system
# prompt) and its answer is what the user saw.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(SCAFFOLD_RECOVERY_ANSWER)
# The deflected message state is intact — the turn took the LOW
# path, the incident's path.
expect(page.locator(".msg.brain").last).to_have_class(re.compile(r"is-deflected"))
_assert_no_raw_tokens(page)
_assert_no_error_banner(page)
# Wire level: the strip happens SERVER-side — no `delta` frame ever
# carries a scaffolding fragment, and the concatenated delta text is
# EXACTLY the recovery answer (request 1's span produced zero delta
# frames). No tool frames, no retry frames, no error frame; the
# turn settled with `done` (deflected).
frames = _drain_frames(page, terminal="done")
assert [f for f in frames if f.get("type") == "delta"], (
"the recovery answer must have streamed delta frames"
)
_assert_no_raw_tokens_on_wire(frames)
assert _delta_text(frames) == SCAFFOLD_RECOVERY_ANSWER
assert not [f for f in frames if f.get("type") == "tool"]
assert not [f for f in frames if f.get("type") == "retry"]
assert not [f for f in frames if f.get("type") == "error"]
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is True
# The recovered turn settles durably: exactly one query_log row
# (deflected — the incident's path).
rows = _query_log_rows()
assert len(rows) == 1, rows
assert rows[0].question == RECOVERY_Q
assert rows[0].deflected is True
# --------------------------------------------------------------------------
# 2. The terminal case: scaffolding twice → the dedicated error frame,
# no done, no query_log row — and the app stays usable
# --------------------------------------------------------------------------
def test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, TERMINAL_Q)
# BOTH requests (original + the one bounded recovery) came back
# scaffolding-only: no clean content ever streamed, so the turn
# settles with the EXISTING terminal error state — the banner
# (role=alert) with the DEDICATED malformed-reply copy — and the
# send button re-enabled (the banner path settles the state
# machine, cf. the phase-67 exhaustion test).
expect(page.locator("#kb-banner")).to_have_attribute(
"role", "alert", timeout=60_000
)
expect(page.locator("#kb-banner")).to_contain_text(MALFORMED_ERROR_COPY)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# No answer bubble was ever rendered (every streamed frame was
# stripped server-side) and no raw token is anywhere in the DOM.
expect(page.locator("#messages > .msg.brain")).to_have_count(0)
_assert_no_raw_tokens(page)
# Wire: the terminal error frame is LAST — no done, and NO delta
# frame at all (both requests' content was pure scaffolding).
frames = _drain_frames(page, terminal="error")
assert frames[-1]["type"] == "error"
assert MALFORMED_ERROR_COPY in frames[-1]["detail"]
assert not [f for f in frames if f.get("type") == "done"]
assert not [f for f in frames if f.get("type") == "delta"]
# Terminal semantics (byte-for-byte the existing LLMError shape):
# the turn writes no query_log row.
assert _query_log_rows() == []
# The app stays usable: a follow-up plain question (no trigger) in
# the SAME session gets a normal streamed deflected answer, the
# banner clears, and the wire is clean.
_submit(page, PLAIN_Q)
_wait_settled(page)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
_assert_no_error_banner(page)
_assert_no_raw_tokens(page)
follow_frames = _drain_frames(page, terminal="done")
assert next(f for f in follow_frames if f["type"] == "done")["deflected"] is True
assert not [f for f in follow_frames if f.get("type") == "error"]
_assert_no_raw_tokens_on_wire(follow_frames)
# --------------------------------------------------------------------------
# 3. No false positive: a plain turn streams its first-request answer
# byte-clean — no strip, no recovery request, no error state
# --------------------------------------------------------------------------
def test_plain_turn_never_recovers_and_streams_byte_clean(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, PLAIN_Q)
_wait_settled(page)
# Wire level: the plain deflected answer streams byte-clean from
# the FIRST request — the concatenated delta text is EXACTLY the
# mock's deterministic deflection answer (derived from the mock
# itself above) and NOT the recovery answer (a recovery request
# would have streamed that text instead).
frames = _drain_frames(page, terminal="done")
delta_text = _delta_text(frames)
assert delta_text == _expected_deflect_answer(PLAIN_Q)
assert SCAFFOLD_RECOVERY_ANSWER not in delta_text
_assert_no_raw_tokens_on_wire(frames)
# No recovery request is visible: no error frame, no retry frame
# (the recovery is not a phase-67 retry), and the turn settled on
# the first request with `done`.
assert not [f for f in frames if f.get("type") == "error"]
assert not [f for f in frames if f.get("type") == "retry"]
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is True
# UI level: the deflected answer rendered, no error banner, no raw
# token in the DOM.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
_assert_no_error_banner(page)
_assert_no_raw_tokens(page)