phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
This commit is contained in:
+568
-34
@@ -73,9 +73,17 @@ from app.models import Document, GitSource
|
||||
from app.rag import agent
|
||||
from app.rag.agent import (
|
||||
AGENT_TOOLS,
|
||||
MISSING_SHOW_TEXT_ARGS,
|
||||
READ_TRUNCATION_NOTICE,
|
||||
SHOW_TEXT_ALREADY_SHOWN,
|
||||
SHOW_TEXT_BLOCK_CAP,
|
||||
SHOW_TEXT_NOT_A_TEXT_DOC,
|
||||
SHOW_TEXT_TOOL,
|
||||
SHOWN_TEXT_TRUNCATION_NOTICE,
|
||||
UNKNOWN_TOOL,
|
||||
AgentHolder,
|
||||
MalformedReplyError,
|
||||
agent_tools,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import (
|
||||
@@ -98,6 +106,20 @@ def _settings(**kwargs: Any) -> Settings:
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
#: Phase 182 (task 03) pin update, deliberate: ``answer_text``
|
||||
#: (``BOR_ANSWER_TEXT``) defaults ON, so the default grounded offer is
|
||||
#: the three base tools (element-for-element ``AGENT_TOOLS``, the
|
||||
#: byte-identity pinned separately by ``test_agent_tools_names_and_
|
||||
#: parameters``) plus the ``show_text`` display tool — every
|
||||
#: "tools stay offered" pin in this module now expects this list. The
|
||||
#: pre-phase offer is still exactly ``AGENT_TOOLS`` with
|
||||
#: ``answer_text=False`` and ``answer_images=False`` (pinned in the
|
||||
#: phase-182 offer-matrix tests below). With ``answer_images=True`` the
|
||||
#: offer is ``[*AGENT_TOOLS, SHOW_IMAGE_TOOL, SHOW_TEXT_TOOL]`` (pinned
|
||||
#: in ``tests/unit/test_image_chips_and_answers.py``).
|
||||
_OFFER = [*AGENT_TOOLS, SHOW_TEXT_TOOL]
|
||||
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||
#: agent formats ``doc.created_at`` on the ``read`` result's second
|
||||
#: line — the detached fixture rows carry it exactly as the NOT NULL
|
||||
@@ -547,9 +569,9 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Phase 45: no per-tool budgets — the tools stay offered on every
|
||||
# request (the round cap, not spent budgets, bounds the loop), so
|
||||
# the answer request still carries them (2 rounds < default cap 10).
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
assert len(llm.requests) == 3
|
||||
|
||||
# read splits the combined form at the FIRST slash — one exact
|
||||
@@ -855,7 +877,7 @@ def test_ls_scoped_known_source_with_zero_docs_counts(
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"].startswith("Homelab — 0 documents, 0 folders:")
|
||||
assert holder.tool_calls == 1 # an executed ls, not a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -873,7 +895,7 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="Ghost")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
|
||||
@@ -900,7 +922,7 @@ def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||||
@@ -921,7 +943,7 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope=".")
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||||
@@ -958,7 +980,7 @@ def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||||
"'Homelab/netwoking' is not a folder — Homelab has: "
|
||||
"backups/ containers/ networking/"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
|
||||
@@ -1527,7 +1549,7 @@ def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatc
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"No document at 'Homelab' — check the ls output."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_read_unknown_path_refused_echoing_argument(
|
||||
@@ -1549,7 +1571,7 @@ def test_read_unknown_path_refused_echoing_argument(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"No document at 'S/ghost.md' — check the ls output."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered (cap bounds)
|
||||
assert llm.requests[1][1] == _OFFER # tools stay offered (cap bounds)
|
||||
|
||||
|
||||
# ---------- read/grep: the "did you mean …?" suggestions (phase 72, task 02) ----------
|
||||
@@ -1649,7 +1671,7 @@ def test_read_bare_path_exact_match_gets_did_you_mean(
|
||||
"No document at 'active/container_caddy/caddy.md' — "
|
||||
"did you mean 'Homelab/active/container_caddy/caddy.md'?"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_read_bare_path_suffix_match_gets_did_you_mean(
|
||||
@@ -1767,7 +1789,7 @@ def test_read_bare_filename_without_slash_keeps_no_db_refusal(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"No document at 'caddy.md' — check the ls output."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_read_bare_path_of_suggested_doc_gets_suggestion_then_succeeds(
|
||||
@@ -1850,7 +1872,7 @@ def test_read_missing_arguments_refused(
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.MISSING_READ_ARGS)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_reading_a_suggested_seed_doc_adds_its_full_text(
|
||||
@@ -1887,7 +1909,7 @@ def test_reading_a_suggested_seed_doc_adds_its_full_text(
|
||||
assert llm.requests[1][0][3]["content"].startswith(
|
||||
"Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT"
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -1938,7 +1960,7 @@ def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT)
|
||||
# Rejected → the tools are still offered on the next request (the
|
||||
# round cap is the only bound).
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
|
||||
|
||||
def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -1960,7 +1982,7 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
)
|
||||
assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT)
|
||||
# Rejected → the tools are still offered on the next request…
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
|
||||
|
||||
# ---------- phase 95: the read cap (bounded reads, honest truncation) ----------
|
||||
@@ -2359,7 +2381,7 @@ def test_bad_range_args_refused_before_any_lookup(
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.BAD_RANGE_ARGS.format(
|
||||
offset=2.5, limit=None
|
||||
))
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
|
||||
|
||||
def test_bad_range_args_beats_the_dedupe(
|
||||
@@ -3678,7 +3700,7 @@ def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.UNKNOWN_TOOL)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
# ---------- phase 145 (task 01): the origin/branch lines in the read result ----------
|
||||
@@ -4062,7 +4084,7 @@ def test_grep_whole_kb_grep_style_output(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [] # locked A5: a grep adds no context
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # tools stay offered
|
||||
|
||||
|
||||
def test_grep_capped_at_20_matches_in_catalog_order(
|
||||
@@ -4370,7 +4392,7 @@ def test_grep_scoped_missing_document_refused(monkeypatch: pytest.MonkeyPatch) -
|
||||
"No document at 'S/ghost.md' — check the ls output."
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
|
||||
@@ -4408,7 +4430,7 @@ def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
|
||||
)
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_grep_scoped_bare_source_name_refused_without_db(
|
||||
@@ -4467,7 +4489,7 @@ def test_grep_missing_arguments_refused(
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"].startswith(agent.MISSING_SEARCH_ARGS)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_grep_no_matches_whole_kb(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -4577,7 +4599,7 @@ def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# after round 1, 8 left after round 2 (10-round budget).
|
||||
assert llm.requests[2][0][3]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=9)
|
||||
assert llm.requests[2][0][5]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=8)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # still offered (no budgets)
|
||||
assert llm.requests[2][1] == _OFFER # still offered (no budgets)
|
||||
|
||||
|
||||
def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -4604,7 +4626,7 @@ def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert llm.requests[2][0][5]["content"].startswith(
|
||||
"Document S/b.md:\ndate: 2024-06-15\nB-CONTENT"
|
||||
)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # the second read was still offered
|
||||
assert llm.requests[2][1] == _OFFER # the second read was still offered
|
||||
|
||||
|
||||
# ---------- round cap (phase 45: replaces the per-tool budgets) ----------
|
||||
@@ -4632,9 +4654,9 @@ def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
]
|
||||
assert len(llm.requests) == 4 # 3 tool rounds + the forced answer
|
||||
# The three tool rounds were offered the tools…
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
# …and the forced final request carries no tools, whatever is left.
|
||||
assert llm.requests[3][1] is None
|
||||
# Every re-list executed and counted.
|
||||
@@ -4686,9 +4708,9 @@ def test_rejected_read_spam_runs_to_round_cap(monkeypatch: pytest.MonkeyPatch) -
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3)))
|
||||
assert len(llm.requests) == 4 # 3 rejected rounds + the forced answer
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[1][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER
|
||||
assert llm.requests[3][1] is None # the forced final request: no tools
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0 # nothing executed
|
||||
refusal = "No document at 'S/ghost.md' — check the ls output."
|
||||
@@ -5101,8 +5123,8 @@ def test_round_retried_before_first_piece(
|
||||
# The restart is byte-identical: same messages, same tools offered.
|
||||
assert len(llm.requests) == 3
|
||||
assert llm.requests[0] == llm.requests[1]
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # the answer round still offered
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[2][1] == _OFFER # the answer round still offered
|
||||
# The flat delay was awaited exactly once, before the retry.
|
||||
assert sleeps == [2.5]
|
||||
tool_logs = [r for r in caplog.records if r.getMessage().startswith("agent tool=")]
|
||||
@@ -5397,7 +5419,7 @@ def test_scaffolding_only_round_gets_exactly_one_recovery(
|
||||
assert pieces == [StreamPiece("content", "The clean recovery answer.")]
|
||||
assert len(llm.requests) == 2 # the round + the one recovery
|
||||
# The round: the original system prompt, the tools offered.
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[0][1] == _OFFER
|
||||
assert llm.requests[0][0] == [
|
||||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||||
{"role": "user", "content": "QUESTION"},
|
||||
@@ -5534,7 +5556,7 @@ def test_scaffolding_round_with_tool_calls_needs_no_recovery(
|
||||
StreamPiece("content", "the answer after the tool"),
|
||||
]
|
||||
assert len(llm.requests) == 2
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # a normal round, not a recovery
|
||||
assert llm.requests[1][1] == _OFFER # a normal round, not a recovery
|
||||
for messages, _tools in llm.requests:
|
||||
assert all(
|
||||
m["content"] is None or agent.CORRECTION_INSTRUCTION not in m["content"]
|
||||
@@ -5615,3 +5637,515 @@ def test_forced_final_scaffolding_only_yields_the_budget_fallback(
|
||||
# calls — this is the fallback path, not the rejection path.
|
||||
assert holder.rejected_calls == 0
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 03) — the 5th agent tool: ``show_text(path, offset?, limit?)``
|
||||
#
|
||||
# The display sibling of ``show_image`` (the phase-124 family in
|
||||
# ``tests/unit/test_image_chips_and_answers.py`` is the template): the model
|
||||
# NAMES a location, the SERVER shows the bytes (LOCKED A1–A3/A6). The suite
|
||||
# pins the offer matrix (``agent_tools``, four base + two display tools, each
|
||||
# behind its own flag), the successful result shape (the ``read``/
|
||||
# ``show_image`` header contract, the SERVER's extent in the ``lines:``
|
||||
# header), ``holder.shown_texts`` (the read-after-stream signal task 05 puts
|
||||
# on the ``done`` frame), and EVERY refusal — each counts in nothing,
|
||||
# appends nothing, and consumes a round like every other refusal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SHOW_SRC = "showsrc"
|
||||
_SHOW_PATH = "quadlets/gufo.container"
|
||||
_SHOW_IDENTITY = f"{_SHOW_SRC}/{_SHOW_PATH}"
|
||||
_SHOW_CONTENT = "L1-one\nL2-two\nL3-three\nL4-four\nL5-five"
|
||||
|
||||
|
||||
def _show_doc(content: str = _SHOW_CONTENT, *, is_image: bool = False) -> Document:
|
||||
"""The fixture row ``show_text`` resolves (detached, like every
|
||||
agent-suite fixture); ``is_image`` flips the A6 refusal case."""
|
||||
doc = _doc(_SHOW_SRC, _SHOW_PATH, "Gufo quadlet", content)
|
||||
doc.is_image = is_image
|
||||
return doc
|
||||
|
||||
|
||||
def _patch_show_doc(
|
||||
monkeypatch: pytest.MonkeyPatch, doc: Document | None
|
||||
) -> None:
|
||||
"""One resolvable document for ``_resolve_path`` (the same
|
||||
monkeypatch style as the read family: the DB accessors are the seam)."""
|
||||
|
||||
def _find(_db: Session, source: str, path: str) -> Document | None:
|
||||
if doc is not None and (source, path) == (doc.source, doc.path):
|
||||
return doc
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
|
||||
|
||||
def _show_text(
|
||||
arguments: dict[str, Any],
|
||||
holder: AgentHolder,
|
||||
settings: Settings | None = None,
|
||||
) -> str:
|
||||
"""One ``show_text`` call through the pure ``_execute_tool`` seam (the
|
||||
budget trailer is appended by ``run_agent``, never here — the raw
|
||||
result strings are what this suite pins)."""
|
||||
return agent._execute_tool(
|
||||
_mock_session(),
|
||||
ToolCallPiece(id="c1", name="show_text", arguments=arguments),
|
||||
[],
|
||||
holder,
|
||||
settings or _settings(),
|
||||
)
|
||||
|
||||
|
||||
# --- the offer matrix (the task-03 completion pin: 4 base + 2 display) ---
|
||||
|
||||
|
||||
def test_show_text_offer_matrix_agent_tools_by_flags() -> None:
|
||||
"""``agent_tools`` offers FOUR tools with ``answer_text=False`` (the
|
||||
pre-phase-182 offer byte-identical — ``show_image`` last when
|
||||
offered) and FIVE with it ON (``show_text`` present, correct order
|
||||
``ls, read, grep, show_image, show_text``); both flags off is
|
||||
element-for-element ``AGENT_TOOLS`` — a fresh list, never the
|
||||
constant."""
|
||||
pre182 = agent_tools(_settings(answer_images=True, answer_text=False))
|
||||
assert [t["function"]["name"] for t in pre182] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
]
|
||||
assert pre182[:3] == AGENT_TOOLS # the base three byte-identical
|
||||
|
||||
both = agent_tools(_settings(answer_images=True, answer_text=True))
|
||||
assert [t["function"]["name"] for t in both] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_image",
|
||||
"show_text",
|
||||
]
|
||||
assert both[:3] == AGENT_TOOLS
|
||||
|
||||
pre124 = agent_tools(_settings(answer_images=False, answer_text=False))
|
||||
assert pre124 == AGENT_TOOLS # element-for-element
|
||||
assert pre124 is not AGENT_TOOLS # a fresh list, never the constant
|
||||
pre124.append({"junk": True}) # mutating the offer must not touch it
|
||||
assert len(AGENT_TOOLS) == 3
|
||||
|
||||
# The shipped default: ``answer_text`` is ON (LOCKED A8), so the
|
||||
# default grounded offer already carries the 5th tool.
|
||||
default = agent_tools(_settings())
|
||||
assert [t["function"]["name"] for t in default] == [
|
||||
"ls",
|
||||
"read",
|
||||
"grep",
|
||||
"show_text",
|
||||
]
|
||||
assert default == _OFFER # the module-wide offer pin stays honest
|
||||
|
||||
|
||||
def test_show_text_tool_definition_is_the_behavior_contract() -> None:
|
||||
"""The same OpenAI function shape as the other tools (one required
|
||||
string ``path`` + the OPTIONAL ``offset``/``limit`` integers), and
|
||||
the description IS the behavior contract (the phase-124 discipline):
|
||||
verbatim display, the do-NOT-retype instruction, the server-owned
|
||||
extent (clamp + past-end teaching), the ``show_image`` pointer
|
||||
(A6), the bare-path teaching mirrored from ``read``, and the
|
||||
one-call-at-a-time discipline."""
|
||||
assert SHOW_TEXT_TOOL["type"] == "function"
|
||||
fn = SHOW_TEXT_TOOL["function"]
|
||||
assert fn["name"] == "show_text"
|
||||
params = fn["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert params["required"] == ["path"]
|
||||
props = params["properties"]
|
||||
assert set(props) == {"path", "offset", "limit"}
|
||||
assert props["path"]["type"] == "string"
|
||||
assert props["offset"]["type"] == "integer"
|
||||
assert props["limit"]["type"] == "integer"
|
||||
# The ``read`` bare-path teaching reused verbatim (one grammar).
|
||||
assert (
|
||||
"A bare document path (without the source name) will not resolve."
|
||||
in props["path"]["description"]
|
||||
)
|
||||
desc = fn["description"]
|
||||
assert "EXACTLY as stored" in desc
|
||||
assert "do NOT retype the content yourself" in desc
|
||||
assert "`offset` (1-based first line) and `limit` (line count)" in desc
|
||||
assert "overshooting `limit` is clamped" in desc
|
||||
assert "refused with the true line" in desc
|
||||
assert "Only text documents — for an image use `show_image`" in desc
|
||||
assert "Call one tool at a time" in desc
|
||||
|
||||
|
||||
def test_show_text_refusal_texts_are_pinned() -> None:
|
||||
"""The four refusal texts are pinned (the model's self-correction
|
||||
relies on stable copy), and the range problems REUSE the read
|
||||
constants — one copy of each string in the codebase."""
|
||||
assert MISSING_SHOW_TEXT_ARGS == "show_text requires a string argument 'path'."
|
||||
assert SHOW_TEXT_NOT_A_TEXT_DOC.format(arg="s/p.png") == (
|
||||
"'s/p.png' is a standalone image document — use show_image to "
|
||||
"display images."
|
||||
)
|
||||
assert SHOW_TEXT_BLOCK_CAP.format(max=5) == (
|
||||
"5 text blocks are already displayed this answer — describe them "
|
||||
"instead of showing more."
|
||||
)
|
||||
assert SHOW_TEXT_ALREADY_SHOWN.format(locator="s/p.md#12-40") == (
|
||||
"s/p.md#12-40 is already on screen this answer — say so instead "
|
||||
"of showing it again."
|
||||
)
|
||||
# The reuse pins: the branch formats THESE constants, not copies —
|
||||
# the exact same teaching strings (and echoed values) ``read`` uses.
|
||||
assert agent.BAD_RANGE_ARGS.format(offset=0, limit=None) == (
|
||||
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
|
||||
"(you passed offset=0, limit=None)."
|
||||
)
|
||||
assert agent.OFFSET_PAST_END.format(
|
||||
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=5, offset=9
|
||||
).startswith(f"{_SHOW_IDENTITY} is 5 lines long — `offset` 9 is past the end.")
|
||||
|
||||
|
||||
# --- the successful show: whole document, ranged, capped ---
|
||||
|
||||
|
||||
def test_show_text_whole_document_success_shape(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Whole-document show (the taught default): result headers in the
|
||||
exact ``read``/``show_image`` order (a NEW first line, then
|
||||
``Document …``, ``date:``, the ``lines:`` extent, the VERBATIM
|
||||
slice), ``shown_texts`` carries the one block (canonical whole-doc
|
||||
locator), ``tool_calls`` counts the executed call, and the READ
|
||||
bookkeeping stays untouched (a show is not a context-adder)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder)
|
||||
assert result == (
|
||||
"Text shown to the user — these lines are displayed verbatim; "
|
||||
"describe them, do NOT retype them.\n"
|
||||
f"Document {_SHOW_IDENTITY}:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"lines: 1-5 of 5\n"
|
||||
f"{_SHOW_CONTENT}"
|
||||
)
|
||||
assert result.splitlines()[1].startswith("Document ") # the mock's contract
|
||||
assert len(holder.shown_texts) == 1
|
||||
block = holder.shown_texts[0]
|
||||
assert block.locator == _SHOW_IDENTITY # whole doc → no range part
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (1, 5, 5)
|
||||
assert block.truncated is False
|
||||
assert holder.tool_calls == 1 # executed-only counting
|
||||
# A show NEVER touches the read paths (it is not a context-adder).
|
||||
assert holder.read_docs == []
|
||||
assert holder.read_coverage == []
|
||||
assert holder.read_truncations == []
|
||||
|
||||
|
||||
def test_show_text_ranged_show_clamps_limit_in_the_lines_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ranged show with an OVERSHOOTING ``limit``: the header carries the
|
||||
SERVER's clamped extent (``lines: 4-5 of 5`` — the caption never
|
||||
lies), the slice is exactly the displayed lines, and the canonical
|
||||
locator carries the clamped range (the D7 matching key)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY, "offset": 4, "limit": 99}, holder)
|
||||
assert result == (
|
||||
"Text shown to the user — these lines are displayed verbatim; "
|
||||
"describe them, do NOT retype them.\n"
|
||||
f"Document {_SHOW_IDENTITY}:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"lines: 4-5 of 5\n"
|
||||
"L4-four\nL5-five"
|
||||
)
|
||||
block = holder.shown_texts[0]
|
||||
assert block.locator == f"{_SHOW_IDENTITY}#4-5"
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (4, 5, 5)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_show_text_capped_block_carries_marker_and_notice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Capped show (``settings.show_text_max_chars``): the slice is cut
|
||||
at a LINE boundary, the shared ``TRUNCATION_MARKER`` rides the block,
|
||||
the ``lines:`` extent SHRINKS to the last displayed line (never the
|
||||
asked extent), and the pinned notice names that the rest is NOT
|
||||
displayed and the user opens the document (no re-show loop implied)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text(
|
||||
{"path": _SHOW_IDENTITY},
|
||||
holder,
|
||||
_settings(show_text_max_chars=13),
|
||||
)
|
||||
assert TRUNCATION_MARKER in result
|
||||
assert result.endswith(
|
||||
SHOWN_TEXT_TRUNCATION_NOTICE.format(source=_SHOW_SRC, path=_SHOW_PATH)
|
||||
)
|
||||
block = holder.shown_texts[0]
|
||||
assert block.truncated is True
|
||||
# The budget (13 chars) lands exactly after line 2 — the caption
|
||||
# shrinks to the lines ACTUALLY shown (never the asked 1-5).
|
||||
assert block.end_line == 2
|
||||
assert block.locator == f"{_SHOW_IDENTITY}#1-2" # cap-derived range
|
||||
assert f"lines: 1-2 of 5\nL1-one\nL2-two\n{TRUNCATION_MARKER}" in result
|
||||
assert holder.tool_calls == 1 # a capped show is still a successful call
|
||||
|
||||
|
||||
def test_show_text_origin_lines_ridge_the_header_like_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The phase-145 origin contract: a git-sourced show carries the
|
||||
``origin:``/``branch:`` lines immediately after ``date:`` and BEFORE
|
||||
``lines:`` (the same ``_origin_lines`` block ``read`` renders; the
|
||||
second line stays ``Document …:``)."""
|
||||
doc = _show_doc()
|
||||
_patch_show_doc(monkeypatch, doc)
|
||||
|
||||
def _factory() -> Session:
|
||||
from app.models import GitSource
|
||||
|
||||
rows = [GitSource(url="https://github.com/owner/showsrc", kind="git", branch="main")]
|
||||
mock = cast("Session", MagicMock())
|
||||
mock.__enter__ = MagicMock(return_value=mock)
|
||||
mock.__exit__ = MagicMock(return_value=False)
|
||||
mock.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=rows)))
|
||||
return mock
|
||||
|
||||
holder = AgentHolder()
|
||||
result = agent._execute_tool(
|
||||
_factory(),
|
||||
ToolCallPiece(id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY}),
|
||||
[],
|
||||
holder,
|
||||
_settings(),
|
||||
)
|
||||
lines = result.splitlines()
|
||||
assert lines[1] == f"Document {_SHOW_IDENTITY}:"
|
||||
assert lines[2] == "date: 2024-06-15"
|
||||
assert lines[3] == "origin: https://github.com/owner/showsrc"
|
||||
assert lines[4] == "branch: main"
|
||||
assert lines[5] == "lines: 1-5 of 5"
|
||||
|
||||
|
||||
# --- the refusal matrix: each counts in nothing, appends nothing ---
|
||||
|
||||
|
||||
def _assert_refusal(holder: AgentHolder) -> None:
|
||||
assert holder.tool_calls == 0
|
||||
assert holder.shown_texts == []
|
||||
assert holder.read_docs == []
|
||||
assert holder.read_coverage == []
|
||||
|
||||
|
||||
def test_show_text_missing_or_non_string_path_refused() -> None:
|
||||
"""Missing, blank and non-string ``path`` → ``MISSING_SHOW_TEXT_ARGS``
|
||||
(the ``MISSING_SHOW_ARGS`` shape); counts in nothing."""
|
||||
holder = AgentHolder()
|
||||
for arguments in ({}, {"path": " "}, {"path": None}, {"path": 42}):
|
||||
assert _show_text(arguments, holder) == MISSING_SHOW_TEXT_ARGS
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_unknown_path_gets_the_did_you_mean_teaching(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The phase-72 teaching, REUSED unchanged: a bare argument keeps the
|
||||
no-DB line, a path-like argument matching one indexed ``path`` gets
|
||||
the combined-identity suggestion — nothing displayed either way."""
|
||||
_patch_show_doc(monkeypatch, None)
|
||||
doc = _show_doc()
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"find_path_candidates",
|
||||
lambda db, arg: [(_SHOW_SRC, _SHOW_PATH, doc.title)],
|
||||
)
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": "quadlets/gufo.container"}, holder)
|
||||
assert result == agent.NO_DOCUMENT_DID_YOU_MEAN.format(
|
||||
arg="quadlets/gufo.container", source=_SHOW_SRC, path=_SHOW_PATH
|
||||
)
|
||||
assert _show_text({"path": "nope"}, holder) == (
|
||||
"No document at 'nope' — check the ls output."
|
||||
)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_on_an_image_doc_is_refused_never_converted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""LOCKED A6: a standalone IMAGE row is refused with the
|
||||
``show_image`` pointer — never silently converted (the exact mirror
|
||||
of ``show_image``'s ``NOT_AN_IMAGE_DOC``); counts in nothing."""
|
||||
_patch_show_doc(monkeypatch, _show_doc(is_image=True))
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder)
|
||||
assert result == SHOW_TEXT_NOT_A_TEXT_DOC.format(arg=_SHOW_IDENTITY)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_bad_range_args_reuse_the_read_teaching(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A non-whole/``< 1`` ``offset``/``limit`` → :data:`BAD_RANGE_ARGS`
|
||||
with the values echoed — the SAME constant the ``read`` branch
|
||||
formats (one copy of the string), fired before any display."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
assert _show_text({"path": _SHOW_IDENTITY, "offset": 0}, holder) == (
|
||||
agent.BAD_RANGE_ARGS.format(offset=0, limit=None)
|
||||
)
|
||||
assert _show_text({"path": _SHOW_IDENTITY, "offset": 1, "limit": "x"}, holder) == (
|
||||
agent.BAD_RANGE_ARGS.format(offset=1, limit="x")
|
||||
)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_offset_past_end_names_the_true_total(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An ``offset`` past the last line → :data:`OFFSET_PAST_END` (the
|
||||
SAME teaching as ``read``) naming the document's TRUE line count;
|
||||
counts in nothing, a corrected retry still proceeds."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY, "offset": 9}, holder)
|
||||
assert result == agent.OFFSET_PAST_END.format(
|
||||
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=5, offset=9
|
||||
)
|
||||
assert "is 5 lines long" in result
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_empty_document_is_refused_like_past_end(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An EMPTY document has nothing to display: the resolver's
|
||||
past-the-end refusal carries the true total (``0 lines long``) —
|
||||
never an empty block, never a crash."""
|
||||
_patch_show_doc(monkeypatch, _show_doc(content=""))
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder)
|
||||
assert result == agent.OFFSET_PAST_END.format(
|
||||
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=0, offset=1
|
||||
)
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
def test_show_text_block_cap_refuses_second_show(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The per-answer block budget (server-enforced): with
|
||||
``show_text_max_blocks=1`` a SECOND, DIFFERENT show is refused with
|
||||
the cap copy — the first block stays displayed, the refusal counts
|
||||
in nothing."""
|
||||
doc_a = _show_doc()
|
||||
doc_b = _doc(_SHOW_SRC, "quadlets/other.container", "Other", "B1\nB2")
|
||||
docs = {doc_a.path: doc_a, doc_b.path: doc_b}
|
||||
|
||||
def _find(_db: Session, source: str, path: str) -> Document | None:
|
||||
return docs.get(path) if source == _SHOW_SRC else None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
holder = AgentHolder()
|
||||
settings = _settings(show_text_max_blocks=1)
|
||||
first = _show_text({"path": _SHOW_IDENTITY}, holder, settings)
|
||||
assert first.startswith("Text shown to the user —")
|
||||
second = _show_text({"path": f"{_SHOW_SRC}/quadlets/other.container"}, holder, settings)
|
||||
assert second == SHOW_TEXT_BLOCK_CAP.format(max=1)
|
||||
assert holder.tool_calls == 1 # the refusal counts in nothing
|
||||
assert len(holder.shown_texts) == 1 # still exactly the first block
|
||||
|
||||
|
||||
def test_show_text_zero_knobs_refuse_the_first_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The ``0`` kill switches (D8): ``show_text_max_blocks=0`` refuses
|
||||
the FIRST call with the cap copy, and ``show_text_max_chars=0`` (the
|
||||
inert per-block budget — nothing could display) refuses with the very
|
||||
same copy instead of reaching the resolver's raise. The tool stays
|
||||
OFFERED either way (the offer is ``answer_text``-gated only); counts
|
||||
in nothing."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
assert _show_text(
|
||||
{"path": _SHOW_IDENTITY}, holder, _settings(show_text_max_blocks=0)
|
||||
) == SHOW_TEXT_BLOCK_CAP.format(max=0)
|
||||
assert _show_text(
|
||||
{"path": _SHOW_IDENTITY}, holder, _settings(show_text_max_chars=0)
|
||||
) == SHOW_TEXT_BLOCK_CAP.format(max=0)
|
||||
_assert_refusal(holder)
|
||||
# The offer is untouched by the budgets (the gate is the flag).
|
||||
assert [t["function"]["name"] for t in agent_tools(
|
||||
_settings(show_text_max_blocks=0, show_text_max_chars=0)
|
||||
)] == ["ls", "read", "grep", "show_text"]
|
||||
|
||||
|
||||
def test_show_text_duplicate_identical_show_refused(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The canonical-locator dedupe: showing the EXACT same block twice
|
||||
is refused (``SHOW_TEXT_ALREADY_SHOWN`` names the locator) — the
|
||||
first block stays on screen, the refusal counts in nothing."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
first = _show_text({"path": _SHOW_IDENTITY, "offset": 2, "limit": 2}, holder)
|
||||
assert first.startswith("Text shown to the user —")
|
||||
second = _show_text({"path": _SHOW_IDENTITY, "offset": 2, "limit": 2}, holder)
|
||||
assert second == SHOW_TEXT_ALREADY_SHOWN.format(locator=f"{_SHOW_IDENTITY}#2-3")
|
||||
assert holder.tool_calls == 1
|
||||
assert len(holder.shown_texts) == 1
|
||||
|
||||
|
||||
def test_show_text_refused_when_answer_text_flag_off(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Defensive posture: with ``answer_text=False`` the tool was never
|
||||
OFFERED, so a call anyway is answered like an un-offered name (the
|
||||
tools-off posture — :data:`UNKNOWN_TOOL`) — counts in nothing,
|
||||
displays nothing (the escape hatch stays byte-identical everywhere)."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
result = _show_text({"path": _SHOW_IDENTITY}, holder, _settings(answer_text=False))
|
||||
assert result == UNKNOWN_TOOL
|
||||
_assert_refusal(holder)
|
||||
|
||||
|
||||
# --- the scripted turn (the completion-criteria wiring pin) ---
|
||||
|
||||
|
||||
def test_show_text_scripted_turn_returns_the_slice_to_the_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Completion pin: with ``answer_text=True`` a scripted agent turn
|
||||
returns a result whose 2nd line starts ``Document `` and whose
|
||||
``lines:`` header matches the slice actually returned; the block
|
||||
rides the holder for the done frame (task 05) and the tool-offer
|
||||
on the request carried the 5th tool."""
|
||||
_patch_show_doc(monkeypatch, _show_doc())
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY, "offset": 3})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings())) # answer_text default ON
|
||||
assert llm.requests[0][1] == _OFFER # the grounded turn offers show_text
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
assert content.endswith(agent._budget_note(9)) # the trailer rides too
|
||||
result = content.removesuffix(agent._budget_note(9))
|
||||
lines = result.splitlines()
|
||||
assert lines[0] == (
|
||||
"Text shown to the user — these lines are displayed verbatim; "
|
||||
"describe them, do NOT retype them."
|
||||
)
|
||||
assert lines[1].startswith("Document ")
|
||||
assert lines[3] == "lines: 3-5 of 5" # offset-only: to the END, clamped
|
||||
assert result.endswith("L3-three\nL4-four\nL5-five") # the slice verbatim
|
||||
assert len(holder.shown_texts) == 1
|
||||
assert holder.shown_texts[0].locator == f"{_SHOW_IDENTITY}#3-5"
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Document, KbOverview, QueryLog
|
||||
from app.rag import agent as rag_agent
|
||||
from app.rag.agent import AGENT_TOOLS
|
||||
from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL
|
||||
from app.rag.llm import StreamPiece, ToolCallPiece
|
||||
from app.rag.prompts import SUGGEST_INTRO, build_deflect_prompt
|
||||
from app.rag.retriever import RetrievedChunk, weak_hit_titles
|
||||
@@ -1135,10 +1135,13 @@ def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 37: a grounded endpoint turn runs the agent loop — the
|
||||
single no-tool-call request carries ``AGENT_TOOLS`` (the default
|
||||
round cap keeps the tools offered), no ``tool`` frames stream, and
|
||||
the ``done`` event is the plain retrieval shape (the tool-free
|
||||
answer is byte-identical)."""
|
||||
single no-tool-call request carries the offered tool list (the
|
||||
default round cap keeps the tools offered), no ``tool`` frames
|
||||
stream, and the ``done`` event is the plain retrieval shape (the
|
||||
tool-free answer is byte-identical). Phase 182 (task 03) pin
|
||||
update, deliberate: the offer is the three base tools plus
|
||||
``show_text`` (``answer_text`` defaults ON), the base three staying
|
||||
element-for-element ``AGENT_TOOLS`."""
|
||||
_session, llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
@@ -1149,7 +1152,12 @@ def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
|
||||
assert frames[-1]["deflected"] is False
|
||||
assert not any(f["type"] == "tool" for f in frames)
|
||||
assert len(llm.seen) == 1
|
||||
assert llm.seen_tools == [AGENT_TOOLS] # one request, tools offered
|
||||
# Phase 182 (task 03): ``answer_text`` defaults ON — the offer is
|
||||
# the base three (byte-identical) plus ``show_text``.
|
||||
assert llm.seen_tools == [[*AGENT_TOOLS, SHOW_TEXT_TOOL]] # one request
|
||||
offered = llm.seen_tools[0]
|
||||
assert offered is not None
|
||||
assert offered[:3] == AGENT_TOOLS # the base offer untouched
|
||||
# The system prompt is the HIGH prompt with the <tools> instructions
|
||||
# (phase 70: the harness-aligned ls/read/grep copy — new names in,
|
||||
# old phase-37/68 names out).
|
||||
@@ -1600,6 +1608,9 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
|
||||
"total_ms=",
|
||||
"retries=",
|
||||
"scaffold_stripped=",
|
||||
# phase 182 (task 05): the PLAN §9 additive tail — the blocks
|
||||
# actually SENT this turn, last on the line.
|
||||
"verbatim_blocks=",
|
||||
)
|
||||
idx = -1
|
||||
for field in order:
|
||||
@@ -1669,3 +1680,140 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
|
||||
line = _last_log_line()
|
||||
assert "thinking=- thinking_chars=" in line
|
||||
assert "thinking=high" not in line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 05): the per-turn log line's ``verbatim_blocks=N`` tail
|
||||
# and the deflected turn's resolve-noting posture (D9). The count is the
|
||||
# blocks ACTUALLY SENT (after the canonical-locator dedupe and the
|
||||
# ``show_text_max_blocks`` cut), so the resolver is faked here — its own
|
||||
# behaviour is pinned in ``tests/unit/test_verbatim_resolver.py`` and the
|
||||
# real end-to-end fusion in ``tests/integration/test_chat_api.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _block(n: int) -> Any:
|
||||
"""One fake resolved block (the resolver's return type — the counting
|
||||
and the frame wiring are what these pins are about)."""
|
||||
from app.rag.verbatim import VerbatimBlock
|
||||
|
||||
return VerbatimBlock(
|
||||
source="Homelab",
|
||||
path=f"quadlet/gufo.container{n}",
|
||||
title=f"gufo{n}",
|
||||
start_line=1,
|
||||
end_line=4,
|
||||
total_lines=12,
|
||||
text=f"[Unit{n}\nExec=gufo",
|
||||
truncated=False,
|
||||
locator=f"Homelab/quadlet/gufo.container{n}#1-4",
|
||||
)
|
||||
|
||||
|
||||
def test_log_line_ends_with_verbatim_blocks_zero_on_a_plain_turn(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A plain grounded turn (no tool blocks, no fence in the answer)
|
||||
ends ``… scaffold_stripped=0 verbatim_blocks=0`` — the PLAN §9 field
|
||||
is UNIFORM across turn shapes (the phase-67 ``retries=N`` pattern),
|
||||
and the ``done`` frame carries no ``answer_text`` key (A9)."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
|
||||
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
frames = _ask(client, "How is my Kubernetes cluster set up?")
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert lines
|
||||
assert lines[-1].endswith("scaffold_stripped=0 verbatim_blocks=0")
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done and "answer_text" not in done[0]
|
||||
|
||||
|
||||
def test_log_line_counts_the_verbatim_blocks_sent(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Two resolved blocks → ``verbatim_blocks=2`` AND both ride the
|
||||
``done`` frame's ``answer_text`` in the resolver's order (the frame
|
||||
and the line count the SAME list — never the raw tool-call count)."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
|
||||
monkeypatch.setattr(
|
||||
chat_api, "resolve_answer_blocks", lambda *_a: [_block(1), _block(2)]
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
frames = _ask(client, "How is my Kubernetes cluster set up?")
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=2")
|
||||
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done
|
||||
assert done[0]["answer_text"] == [
|
||||
{
|
||||
"source": "Homelab",
|
||||
"path": "quadlet/gufo.container1",
|
||||
"title": "gufo1",
|
||||
"start_line": 1,
|
||||
"end_line": 4,
|
||||
"total_lines": 12,
|
||||
"text": "[Unit1\nExec=gufo",
|
||||
"truncated": False,
|
||||
"locator": "Homelab/quadlet/gufo.container1#1-4",
|
||||
},
|
||||
{
|
||||
"source": "Homelab",
|
||||
"path": "quadlet/gufo.container2",
|
||||
"title": "gufo2",
|
||||
"start_line": 1,
|
||||
"end_line": 4,
|
||||
"total_lines": 12,
|
||||
"text": "[Unit2\nExec=gufo",
|
||||
"truncated": False,
|
||||
"locator": "Homelab/quadlet/gufo.container2#1-4",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_deflected_turn_resolves_nothing_and_logs_zero(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""D9: a DEFLECTED turn resolves NOTHING even when its own answer
|
||||
text carries a `` ```bor `` fence — a hallucinated locator must not
|
||||
leak KB text through the turn that just disclaimed it. The resolver
|
||||
is never called, the line reads ``verbatim_blocks=0`` and the frame
|
||||
carries no ``answer_text`` key."""
|
||||
_session, llm = gate_env
|
||||
llm.answer = (
|
||||
"I haven't done anything like that.\n"
|
||||
"```bor Homelab/quadlet/gufo.container 1-4\n"
|
||||
"Exec=/usr/bin/lie\n"
|
||||
"```"
|
||||
)
|
||||
doc = _doc("Sourdough Starter", "BREAD_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.10)]))
|
||||
calls: list[Any] = []
|
||||
monkeypatch.setattr(
|
||||
chat_api,
|
||||
"resolve_answer_blocks",
|
||||
lambda *_a: calls.append(1) or [], # pyright: ignore[reportFunctionMemberAccess]
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO", logger="app.chat"):
|
||||
frames = _ask(client, "How do I bake sourdough bread?")
|
||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||
assert lines and lines[-1].endswith("verbatim_blocks=0")
|
||||
assert calls == [] # the resolver never ran on a deflected turn
|
||||
done = [f for f in frames if f.get("type") == "done"]
|
||||
assert done and done[0]["deflected"] is True
|
||||
assert "answer_text" not in done[0]
|
||||
|
||||
@@ -113,7 +113,9 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
|
||||
# per-call behavior override (default SCROLL instead of forced
|
||||
# "auto" — documented at the call site).
|
||||
assert 'addMessage("user", renderMarkdown(m.text), true)' in js
|
||||
assert 'addMessage("brain", renderMarkdown(m.text), true)' in js
|
||||
# Phase 182 (task 07): the brain restore renderer opts INTO the verbatim
|
||||
# pass (the user renderer does not — a question is never a locator).
|
||||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in js
|
||||
assert "wrap.classList.add(\"is-deflected\")" in js
|
||||
assert "appendMaybeTry(wrap, m.suggestions)" in js
|
||||
assert "appendSources(wrap, m.sources)" in js
|
||||
@@ -289,7 +291,9 @@ def test_brain_turn_persists_optional_thinking_field() -> None:
|
||||
'const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "")'
|
||||
in branch
|
||||
)
|
||||
assert "renderMarkdown(finalText)" in branch, (
|
||||
# Phase 182 (task 07): the fallback renders through the SAME opt-in as
|
||||
# the per-delta write (ONE answer renderer, one shape).
|
||||
assert "renderMarkdown(finalText, { verbatim: true })" in branch, (
|
||||
"the substituted fallback must render into the bubble"
|
||||
)
|
||||
|
||||
|
||||
@@ -426,6 +426,76 @@ def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
_settings()
|
||||
|
||||
|
||||
def test_answer_text_default_true_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 182 (LOCKED A8): the verbatim-display master switch defaults
|
||||
to ON — an answer whose code block silently differs from the file is a
|
||||
correctness bug, so the fix ships on and the switch is an escape hatch
|
||||
(unlike phase 124's ``answer_images``, which defaults OFF). Env-tunable
|
||||
in both directions; ``0``/``false`` is the byte-identical pre-phase
|
||||
state (no tool, no clause, no resolution, no ``answer_text`` key)."""
|
||||
monkeypatch.delenv("BOR_ANSWER_TEXT", raising=False)
|
||||
assert _settings().answer_text is True
|
||||
for off in ("0", "false"):
|
||||
monkeypatch.setenv("BOR_ANSWER_TEXT", off)
|
||||
assert _settings().answer_text is False
|
||||
for on in ("1", "true"):
|
||||
monkeypatch.setenv("BOR_ANSWER_TEXT", on)
|
||||
assert _settings().answer_text is True
|
||||
|
||||
|
||||
def test_show_text_max_chars_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 182 (LOCKED A8): one displayed verbatim slice is capped at
|
||||
``BOR_SHOW_TEXT_MAX_CHARS`` (default 12 000 chars — 5 blocks is a 60 KB
|
||||
worst case against the 256 KB response budget). ``0`` is accepted and
|
||||
means INERT (no block displays) — not a startup error."""
|
||||
monkeypatch.delenv("BOR_SHOW_TEXT_MAX_CHARS", raising=False)
|
||||
assert _settings().show_text_max_chars == 12_000
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "500")
|
||||
assert _settings().show_text_max_chars == 500
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "0")
|
||||
assert _settings().show_text_max_chars == 0
|
||||
|
||||
|
||||
def test_show_text_max_chars_rejects_negative(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A negative per-block cap is a typo — it would slice from the END of
|
||||
the content instead of failing, so startup refuses naming the field
|
||||
(the ``read_max_chars`` pattern, phase 182)."""
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "-1")
|
||||
with pytest.raises(ValidationError, match="show_text_max_chars"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_show_text_max_blocks_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 182 (LOCKED A8): at most ``BOR_SHOW_TEXT_MAX_BLOCKS`` verbatim
|
||||
blocks display per answer (default 5, counted in answer order). ``0``
|
||||
is accepted and means INERT (no block displays)."""
|
||||
monkeypatch.delenv("BOR_SHOW_TEXT_MAX_BLOCKS", raising=False)
|
||||
assert _settings().show_text_max_blocks == 5
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "1")
|
||||
assert _settings().show_text_max_blocks == 1
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "0")
|
||||
assert _settings().show_text_max_blocks == 0
|
||||
|
||||
|
||||
def test_show_text_max_blocks_rejects_negative(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A negative per-answer block cap is a typo — it would count down
|
||||
from the END of the block list instead of failing, so startup refuses
|
||||
naming the field (the ``agent_max_rounds`` pattern, phase 182)."""
|
||||
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "-1")
|
||||
with pytest.raises(ValidationError, match="show_text_max_blocks"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_recency_boost_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -887,7 +887,10 @@ def test_renderer_extracted_to_shared_markdown_js() -> None:
|
||||
and BOTH pages load it via a relative <script src> before their module
|
||||
scripts — so the globals exist when app.js/document.js run."""
|
||||
md = _read(MARKDOWN_JS)
|
||||
assert "function renderMarkdown(md)" in md
|
||||
# Phase 182 (task 06) added the SECOND, optional `opts` argument — the
|
||||
# renderer still lives here exactly once, which is what this pin is
|
||||
# about (the argument is pinned by tests/unit/test_frontend_verbatim_markdown.py).
|
||||
assert "function renderMarkdown(md" in md
|
||||
assert "function escapeHtml(s)" in md
|
||||
|
||||
app_js = _read(APP_JS)
|
||||
|
||||
@@ -982,6 +982,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
|
||||
// set first, so the catch never reads the guard's
|
||||
// abort as a user stop
|
||||
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
|
||||
// still reading "Retrieving the exact text…" would be a lie by then
|
||||
// (the hydrate at `done` never runs), so settle it to the honest state.
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
|
||||
});
|
||||
|
||||
@@ -1283,7 +1287,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// NEW run in the container — the D15 re-open of the shared
|
||||
// block is gone). Idempotent — a no-op without an active run.
|
||||
closeCurrentRun();
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
|
||||
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
|
||||
// here (the model's re-typed body DROPPED, locked A7) that the `done`
|
||||
// branch below hydrates against the frame's server-resolved blocks —
|
||||
// mid-stream the reader sees "Retrieving the exact text…", never a
|
||||
// copy that might be wrong. Every other renderer on the page (user
|
||||
// bubbles, thinking runs, the document viewer) stays one-argument.
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
|
||||
// No page scroll (phase 42): the answer never follows the viewport.
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
@@ -1315,6 +1326,25 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// figure is additive — "summaries should still be provided");
|
||||
// a frame without it renders byte-identically (no figure DOM).
|
||||
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
|
||||
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
|
||||
// `ev.answer_text`, the bytes the SERVER read out of the index — land
|
||||
// in the SAME band as the answer image (below the text, above the
|
||||
// source clusters): hydrate every placeholder the renderer painted,
|
||||
// then append the blocks the answer never located (a `show_text` call
|
||||
// the prose never named). A locator the server could not resolve
|
||||
// renders the honest unresolved sentence with NONE of the model's
|
||||
// bytes, and no panel is left at "Retrieving the exact text…".
|
||||
hydrateVerbatim(
|
||||
wrap.querySelector(".bubble"),
|
||||
ev.answer_text || [],
|
||||
VERBATIM_LINK_OPTS
|
||||
);
|
||||
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
// The turn SETTLED — this is where the ordinary code blocks get their
|
||||
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
|
||||
// scratch, and a button that vanishes on the next token is not a
|
||||
// control). NO badge, NO caption on those (locked A5).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
appendSources(wrap, ev.sources);
|
||||
// Phase 113 (task 02): the cited tier stays the citation
|
||||
// surface (the chips above); the related tier (scored docs
|
||||
@@ -1332,7 +1362,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// what gets persisted.
|
||||
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
||||
if (!acc && sawThinking) {
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
||||
// Phase 182 (task 07): the same opt-in as the per-delta write —
|
||||
// this line paints the answer the user keeps (the fallback text,
|
||||
// so no placeholder exists in practice, but the ONE answer
|
||||
// renderer must stay ONE code path in shape).
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
|
||||
}
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata; phase 17: + optional
|
||||
@@ -1359,6 +1393,13 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// the precedent): an image-less / pre-phase turn stores a
|
||||
// byte-identical record.
|
||||
answer_image: ev.answer_image || undefined,
|
||||
// Phase 182 (task 07): the verbatim blocks persist with the turn
|
||||
// (task 05's additive `answer_text` key) so a reload — and a shared
|
||||
// page — re-renders the identical panel. `undefined` drops the key
|
||||
// from the JSON (the house optional-meta pattern, the answer_image
|
||||
// line above is the precedent): a pre-phase or answer_text=False
|
||||
// record stays byte-identical.
|
||||
answer_text: ev.answer_text || undefined,
|
||||
// Phase 113 (task 02): the related tier persists with the
|
||||
// turn (undefined drops the key from the JSON — the house
|
||||
// optional-meta pattern), so the restore path re-renders the
|
||||
@@ -1431,6 +1472,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
}
|
||||
}
|
||||
} else if (ev.type === "error") {
|
||||
// Phase 182 (task 07): an `error` frame is terminal — the `done`
|
||||
// hydrate will never run, so a placeholder that streamed in stays at
|
||||
// "Retrieving the exact text…" forever unless it settles HERE (the
|
||||
// honest unresolved state, never a stale promise of the exact text).
|
||||
settleVerbatimPlaceholders(
|
||||
wrap?.querySelector(".bubble"),
|
||||
"unverified"
|
||||
);
|
||||
// Phase 114 (TODO L6): carry the frame's optional hint (the
|
||||
// "question too long" frame has one) through the throw — the
|
||||
// banner shows it in place of the default reachability hint.
|
||||
@@ -1485,6 +1534,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborted) {
|
||||
// Phase 182 (task 07): defensive settle for the guard's own abort
|
||||
// surface (the timeout callback already ran it — idempotent, and the
|
||||
// turn must not leave a pending panel on ANY terminal path).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
// The 300s guard already took the turn to the error state — its
|
||||
// own abort surfaces here, and it is never read as a user stop.
|
||||
// Phase 159 (task 03): the aborted settle must not strand the
|
||||
@@ -1505,6 +1558,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// convention; no sources/suggestions — the turn never settled).
|
||||
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
|
||||
// which aborts the same fetch — from saving the partial twice.
|
||||
// Phase 182 (task 07): the STOP is terminal for this answer too — a
|
||||
// placeholder in the kept partial settles to the honest state (the
|
||||
// blocks never arrive without a `done` frame).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
if (wrap && acc && !persistedOnLeave) {
|
||||
// Phase 158 (D5): the turn was stopped — fold every open run
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
@@ -1635,14 +1692,23 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
|
||||
list-of-objects no longer stringifies to ``[object Object]``; the
|
||||
task-03 pre-stream throw carries ``bubbleText`` (FAILED_REQUEST_TEXT)
|
||||
+ the phase-114 ``hint``, and the catch forwards ``bubbleText:
|
||||
err?.bubbleText`` into the failed-turn funnel — locked A3): the
|
||||
full source must match the pin — the ``reask`` path, the turn-local
|
||||
err?.bubbleText`` into the failed-turn funnel — locked A3)
|
||||
and phase 182 (task 07)'s sanctioned additions (the two answer
|
||||
renderers pass the verbatim opt-in — the per-delta write and the
|
||||
``done``-time re-render; the done settle hydrates the `` ```bor ``
|
||||
placeholders against ``ev.answer_text``, appends the blocks the answer
|
||||
never located, and gives the settled answer's ordinary code blocks their
|
||||
Copy buttons BEFORE ``appendSources``; the ``answer_text`` meta key rides
|
||||
``rememberBrainTurn`` with ``|| undefined``; and the terminal paths — the
|
||||
``error`` frame, the 300 s guard, its own abort surface, and the Stop
|
||||
branch — settle any leftover placeholder to the unresolved state):
|
||||
the full source must match the pin — the ``reask`` path, the turn-local
|
||||
resets, the
|
||||
SSE contract, the save points, and the sanctioned additions (the
|
||||
task-01 ``lastUserWrap`` assignment + the idle-settle mark
|
||||
pass, the task-03 aborted-settle mark pair, the phase-160
|
||||
done-settle pair, the phase-161 live-container additions, and
|
||||
the D5 refinement) included."""
|
||||
done-settle pair, the phase-161 live-container additions, the
|
||||
D5 refinement, and the phase-182 verbatim settle/hydrate pair) included."""
|
||||
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
|
||||
|
||||
|
||||
|
||||
@@ -173,7 +173,11 @@ def test_restore_landing_is_one_shot() -> None:
|
||||
js = _js()
|
||||
body = _fn_body(js, "renderStoredMessage")
|
||||
assert 'addMessage("user", renderMarkdown(m.text), true)' in body
|
||||
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
|
||||
# Phase 182 (task 07): the brain renderer gained the verbatim option —
|
||||
# the SCROLL intent (the trailing `true`) is untouched, and the option
|
||||
# object (`{ verbatim: true }`) never ends in `", true)"`, so the count
|
||||
# below still counts the two restore landings alone.
|
||||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
|
||||
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
|
||||
# Submit reveal + the two restore landings — nothing else scrolls.
|
||||
# Phase 123 (task 02): the submit call is multi-line (the optional
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
"""Unit: the verbatim panel client and the answer's Copy buttons (phase 182, task 07).
|
||||
|
||||
The browser half of the story lives in the phase's Playwright suite (task
|
||||
08); this module pins the SOURCE contract of ``frontend/assets/verbatim.js``
|
||||
and the three client surfaces that consume it, in the established
|
||||
``test_frontend_*`` convention — plus a node-driven half (skipped when node
|
||||
is unavailable, the phase-44/138/180/182 idiom) that runs the panel's PURE
|
||||
logic (caption, gutter, locator match, block guard) against the real file.
|
||||
|
||||
Why these particular pins (the phase's drift risks, in order):
|
||||
|
||||
* **ONE locator grammar.** ``markdown.js::borLocator`` is the client's single
|
||||
normalizer (task 06); hydration may only compare the server's canonical
|
||||
``locator`` against the already-normalized ``data-bor-locator`` with a
|
||||
plain string ``===``. A second grammar in a second file is the phase's main
|
||||
way to break silently — so this module pins that no normalizer exists in
|
||||
``verbatim.js`` (no ``borLocator``, no range regex, no ``parseInt``).
|
||||
* **The model's bytes never reach the screen or the clipboard.** The panel's
|
||||
text node is ``pre.textContent = block.text`` (server bytes, verbatim) and
|
||||
the Copy button copies the same string — never the DOM, never the gutter,
|
||||
never the model's re-typed fence body (which task 06 dropped upstream).
|
||||
* **The claim is carried by words, not colours** (LOCKED A5): the badge text
|
||||
is ``VERBATIM``, the honest state is the pinned sentence ``I could not
|
||||
verify this against the knowledge base``, and an ordinary code block gets a
|
||||
Copy button with NO badge.
|
||||
* **Parity.** The live turn, the reload restore and the anonymous shared page
|
||||
call the SAME globals, in the SAME order (hydrate → append the unlocated →
|
||||
settle the code blocks, all inside the ``answer_image`` band and BEFORE the
|
||||
source clusters), so one answer cannot render three different ways.
|
||||
* **No panel is ever left lying.** Every terminal path — the ``error`` frame,
|
||||
the 300 s guard, the Stop action, the failed-turn funnel — settles the
|
||||
leftover placeholders; nothing reads "Retrieving the exact text…" after the
|
||||
turn is over.
|
||||
* **CSP + the house XSS rule.** Everything is ``createElement`` +
|
||||
``textContent``, every handler is ``addEventListener``, and there is no
|
||||
``innerHTML``/``onclick=``/``oncopy=`` anywhere in the new client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
VERBATIM_JS = ASSETS / "verbatim.js"
|
||||
APP_JS = ASSETS / "app.js"
|
||||
SHARED_JS = ASSETS / "shared.js"
|
||||
DOCUMENT_JS = ASSETS / "document.js"
|
||||
MARKDOWN_JS = ASSETS / "markdown.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html"
|
||||
CONTAINERFILE = FRONTEND.parent / "Containerfile"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
#: The labelled phase-182 CSS region (task 06's markers — task 07 extends
|
||||
#: INSIDE it, so the palette-only and contiguity pins keep holding).
|
||||
BLOCK_BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
|
||||
BLOCK_END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
|
||||
|
||||
#: The honest state (pinned copy — task 07's Testing section).
|
||||
UNRESOLVED_COPY = "I could not verify this against the knowledge base"
|
||||
|
||||
#: The badge's WORD (LOCKED A5) and the tooltip that explains it.
|
||||
BADGE_TEXT = "VERBATIM"
|
||||
BADGE_TITLE = "These bytes are copied from the knowledge base, not written by the model."
|
||||
|
||||
#: The Copy control's accessible name template — the FULL path, never a
|
||||
#: clipped stand-in (the phase-156 clipped-label rule).
|
||||
COPY_ARIA = "Copy ${shown} lines from ${identity}"
|
||||
|
||||
#: The globals task 07 exposes for the two page modules.
|
||||
PUBLIC_GLOBALS = (
|
||||
"buildVerbatimPanel",
|
||||
"hydrateVerbatim",
|
||||
"appendUnlocatedVerbatim",
|
||||
"settleVerbatimPlaceholders",
|
||||
"enhanceAnswerCodeBlocks",
|
||||
"copyTextWithFallback",
|
||||
)
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing file: {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _strip_comments(text: str) -> str:
|
||||
"""Comments out — the CSP and literal pins below are about CODE. The
|
||||
file's own header documents the very patterns it forbids (``onclick=``,
|
||||
``javascript:``), and a prose-blind grep would flag the documentation of
|
||||
the rule as the rule."""
|
||||
text = re.sub(r"/\*[\s\S]*?\*/", "", text)
|
||||
return re.sub(r"(?m)^\s*//.*$", "", text)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level (optionally ``async``) ``function <name>``."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
def _body(js: str, name: str) -> str:
|
||||
return _fn(js, name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the client ships, and ships in the right shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verbatim_js_is_a_classic_script_after_markdown_on_both_pages() -> None:
|
||||
"""Both pages load it as a PLAIN ``<script>`` immediately AFTER
|
||||
``markdown.js`` (whose placeholders it hydrates) and BEFORE their page
|
||||
module (which calls it as a global). Absolute paths on ``shared.html``
|
||||
(the nested ``/shared/<token>`` route breaks a relative ref), relative on
|
||||
``index.html`` exactly like its neighbours. No ``type="module"`` — a
|
||||
module would defer past the pages' boot and expose no globals."""
|
||||
index = _text(INDEX_HTML)
|
||||
shared = _text(SHARED_HTML)
|
||||
assert '<script src="assets/verbatim.js"></script>' in index
|
||||
assert '<script src="/assets/verbatim.js"></script>' in shared
|
||||
assert index.index('src="assets/markdown.js"') < index.index('src="assets/verbatim.js"'), (
|
||||
"verbatim.js hydrates markdown.js's placeholders — it loads after it"
|
||||
)
|
||||
assert index.index('src="assets/verbatim.js"') < index.index(
|
||||
'type="module" src="/assets/app.js"'
|
||||
), "the globals must exist before app.js evaluates"
|
||||
assert shared.index('src="/assets/markdown.js"') < shared.index('src="/assets/verbatim.js"')
|
||||
assert shared.index('src="/assets/verbatim.js"') < shared.index(
|
||||
'type="module" src="/assets/shared.js"'
|
||||
)
|
||||
tag = next(line for line in index.splitlines() if "verbatim.js" in line and "<script" in line)
|
||||
assert 'type="module"' not in tag, "a classic script — the globals are the API"
|
||||
# The document viewer never loads it (a file's own ```bor fence is text).
|
||||
document_html = _text(FRONTEND / "document.html")
|
||||
assert "verbatim.js" not in document_html
|
||||
|
||||
|
||||
def test_containerfile_produces_it_as_a_minified_classic_script() -> None:
|
||||
"""Stage 1 builds it the way it builds ``markdown.js``: ``--minify``
|
||||
WITHOUT ``--bundle`` (bundling a classic script would break the global
|
||||
exposure), and the source itself stays free of top-level
|
||||
``import``/``export``."""
|
||||
container = _text(CONTAINERFILE)
|
||||
line = next(
|
||||
row for row in container.splitlines() if "assets/verbatim.js" in row
|
||||
)
|
||||
assert "--minify" in line
|
||||
assert "--outfile=/out/assets/verbatim.js" in line
|
||||
assert "--bundle" not in line, "a classic script is never bundled"
|
||||
assert "--format=esm" not in line
|
||||
js = _text(VERBATIM_JS)
|
||||
assert "\nimport " not in js and "\nexport " not in js
|
||||
|
||||
|
||||
def test_every_public_global_exists() -> None:
|
||||
"""The API the two page modules call, as top-level function declarations
|
||||
(the ``markdown.js`` exposure pattern — a classic script's top-level
|
||||
functions ARE the globals)."""
|
||||
js = _text(VERBATIM_JS)
|
||||
for name in PUBLIC_GLOBALS:
|
||||
assert f"function {name}(" in js, f"verbatim.js must expose {name}"
|
||||
|
||||
|
||||
def test_no_locator_normalizer_is_re_implemented() -> None:
|
||||
"""THE phase's drift pin: hydration compares the server's canonical
|
||||
``locator`` against the ``data-bor-locator`` markdown.js already
|
||||
normalized, with a PLAIN string compare. No grammar here — no
|
||||
``borLocator``, no range regex, no en-dash handling, no ``parseInt``, no
|
||||
``L``-prefix stripping (a second grammar in a second file is how a
|
||||
resolved block stops hydrating)."""
|
||||
js = _text(VERBATIM_JS)
|
||||
assert "function borLocator" not in js
|
||||
assert "parseInt" not in js
|
||||
assert "\u2013" not in _fn(js, "matchVerbatimBlock"), "no en-dash parsing here"
|
||||
for pattern in ("[Ll]", "L?\\d", "#L", "trimStart", "normalize("):
|
||||
assert pattern not in js, f"{pattern} looks like a re-implemented grammar"
|
||||
match = _fn(js, "matchVerbatimBlock")
|
||||
assert "block.locator === key" in match, "the exact match is a plain string compare"
|
||||
hydrate = _fn(js, "hydrateVerbatim")
|
||||
assert 'node.getAttribute("data-bor-locator")' in hydrate, (
|
||||
"hydration reads the attribute markdown.js wrote — it never re-derives it"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the panel's contract (LOCKED A2/A5/A7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_panel_is_a_labelled_group_keyed_by_the_servers_locator() -> None:
|
||||
"""``role="group"`` + ``aria-label="Verbatim text from <path>, lines
|
||||
N\u2013M"`` + ``data-bor-locator`` = the SERVER's canonical locator (the
|
||||
key ``appendUnlocatedVerbatim`` later recognises, which is why a
|
||||
re-render never duplicates a panel)."""
|
||||
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
|
||||
assert 'root.setAttribute("role", "group")' in panel
|
||||
assert 'root.setAttribute("aria-label", verbatimPanelLabel(block))' in panel
|
||||
assert "root.dataset.borLocator" in panel
|
||||
assert "block.locator" in panel, "the server's locator is the panel's identity"
|
||||
label = _fn(_text(VERBATIM_JS), "verbatimPanelLabel")
|
||||
assert '"Verbatim text from' in json.dumps(label) or "Verbatim text from" in label
|
||||
|
||||
|
||||
def test_badge_is_a_word_with_the_pinned_explanation() -> None:
|
||||
"""LOCKED A5: the claim is the WORD ``VERBATIM`` (never an icon or a
|
||||
colour), with the tooltip that says what it means."""
|
||||
js = _text(VERBATIM_JS)
|
||||
assert f'badge.textContent = "{BADGE_TEXT}"' in js
|
||||
assert BADGE_TITLE in js
|
||||
assert 'badge.className = "verbatim-badge"' in js
|
||||
|
||||
|
||||
def test_unresolved_state_is_the_pinned_sentence_and_nothing_else() -> None:
|
||||
"""The honest state for a locator the server did not resolve: the pinned
|
||||
sentence, plus the document link ONLY when the identity parsed into a
|
||||
``source/path``. NO badge, NO body, NONE of the model's bytes — and the
|
||||
builder creates neither a badge nor a ``<pre>`` node at all, which is
|
||||
stronger than hiding one."""
|
||||
js = _text(VERBATIM_JS)
|
||||
assert f'"{UNRESOLVED_COPY}"' in js
|
||||
node = _fn(js, "buildVerbatimUnresolved")
|
||||
assert 'root.className = "verbatim verbatim-unresolved"' in node
|
||||
assert 'state.className = "verbatim-state"' in node
|
||||
assert "verbatim-badge" not in node, "an unresolved panel never badges"
|
||||
assert 'createElement("pre")' not in node, "and never has a body"
|
||||
assert 'createElement("ol")' not in node, "nor a gutter"
|
||||
assert "indexOf(" in node and "slash > 0" in node, (
|
||||
"the link appears only when the identity split into a source and a path"
|
||||
)
|
||||
|
||||
|
||||
def test_copy_button_names_the_full_path_and_copies_the_servers_bytes() -> None:
|
||||
"""The completion criterion at source level: the panel's Copy control is
|
||||
built from ``block.text`` (never from the DOM, never with the gutter's
|
||||
digits), and its accessible name is ``Copy N lines from <source/path>`` —
|
||||
the full path, because the visible label is the CSS clip target
|
||||
(phase 156)."""
|
||||
js = _text(VERBATIM_JS)
|
||||
panel = _fn(js, "buildVerbatimPanel")
|
||||
assert COPY_ARIA in panel
|
||||
build = panel[panel.index("buildCopyButton(") :]
|
||||
assert '"verbatim-copy"' in build
|
||||
assert "block.text," in build.split("\n)")[0], (
|
||||
"the copied string is `block.text` — the exact bytes the panel shows"
|
||||
)
|
||||
control = _fn(js, "buildCopyButton")
|
||||
assert 'btn.setAttribute("aria-label", ariaLabel)' in control
|
||||
assert 'label.textContent = "Copy"' in control
|
||||
assert 'label.textContent = "Copied"' in control, "the state is TEXT (WCAG 1.4.1)"
|
||||
assert 'btn.classList.add("copied")' in control
|
||||
clipboard = _fn(js, "copyTextWithFallback")
|
||||
assert "await navigator.clipboard.writeText(text)" in clipboard, (
|
||||
"the caller's string goes to the clipboard untouched — no DOM read"
|
||||
)
|
||||
|
||||
|
||||
def test_body_is_the_servers_text_with_the_servers_line_numbers() -> None:
|
||||
"""The bounded body: the ``<ol>`` gutter (``aria-hidden`` — the range
|
||||
rides the panel's label, and the digits are ``user-select: none`` in CSS
|
||||
so a selection never drags them into the clipboard) and the ``<pre>``
|
||||
whose ``textContent`` is ``block.text``, byte for byte."""
|
||||
js = _text(VERBATIM_JS)
|
||||
panel = _fn(js, "buildVerbatimPanel")
|
||||
assert 'body.className = "verbatim-body"' in panel
|
||||
assert 'numbers.className = "verbatim-lines"' in panel
|
||||
assert 'numbers.setAttribute("aria-hidden", "true")' in panel
|
||||
assert 'pre.className = "verbatim-text"' in panel
|
||||
assert "pre.textContent = block.text" in panel
|
||||
assert "li.textContent = row.number" in panel
|
||||
gutter = _fn(js, "verbatimGutter")
|
||||
assert 'split("\\n")' in gutter, "one gutter row per line of the SERVER text"
|
||||
|
||||
|
||||
def test_truncated_block_says_the_rest_is_in_the_document() -> None:
|
||||
"""A capped block appends ONE muted line plus the same viewer link — the
|
||||
panel never pretends the slice is the whole file."""
|
||||
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
|
||||
assert "if (block.truncated)" in panel
|
||||
assert 'more.className = "verbatim-more"' in panel
|
||||
assert "the rest is in the document" in panel
|
||||
assert "buildVerbatimOpenLink(" in panel.split("if (block.truncated)")[1]
|
||||
|
||||
|
||||
def test_open_link_is_a_real_href_with_the_modal_hook() -> None:
|
||||
"""The panel links to the document viewer: an always-same-origin
|
||||
``/document.html?source=…&path=…`` built with ``encodeURIComponent`` (so a
|
||||
path can never turn into ``javascript:``/``data:``), plus the chat page's
|
||||
click-to-modal hook through ``opts.onOpen`` — attached with
|
||||
``addEventListener``, never an inline attribute."""
|
||||
js = _text(VERBATIM_JS)
|
||||
link = _fn(js, "buildVerbatimOpenLink")
|
||||
assert 'link.className = "verbatim-open"' in link
|
||||
assert "link.addEventListener(" in link
|
||||
assert "event.preventDefault()" in link
|
||||
assert "opts.onOpen(source, path, link)" in link
|
||||
href = _fn(js, "verbatimDocumentHref")
|
||||
assert "/document.html?source=" in href
|
||||
assert "encodeURIComponent(source)" in href and "encodeURIComponent(path)" in href
|
||||
code = _strip_comments(js)
|
||||
assert "javascript:" not in code and "data:" not in code, (
|
||||
"no scheme is ever assembled into a link target"
|
||||
)
|
||||
|
||||
|
||||
def test_plain_answer_code_blocks_get_copy_and_never_a_badge() -> None:
|
||||
"""``enhanceAnswerCodeBlocks``: every ``<pre>`` in an answer bubble that
|
||||
is NOT inside a ``.verbatim`` gets ``.code-block`` + ``.code-block-copy``.
|
||||
NO badge and NO caption — verbatim-and-badged OR plainly the model's,
|
||||
never a hybrid (LOCKED A5). Idempotent: a ``<pre>`` already inside a
|
||||
``.code-block`` is left alone."""
|
||||
fn = _fn(_text(VERBATIM_JS), "enhanceAnswerCodeBlocks")
|
||||
assert 'box.className = "code-block"' in fn
|
||||
assert '"code-block-copy"' in fn
|
||||
assert 'pre.closest(".verbatim")' in fn, "a panel owns its own Copy button"
|
||||
assert 'parent.classList.contains("code-block")' in fn, "idempotent on re-render"
|
||||
assert "verbatim-badge" not in fn
|
||||
assert "verbatim-caption" not in fn
|
||||
|
||||
|
||||
def test_hydration_is_idempotent_and_never_throws_on_garbage() -> None:
|
||||
"""Only ``.verbatim-pending`` is replaced, so a hydrated panel (or one
|
||||
restored from storage) survives a re-run with its ``Copied`` state and
|
||||
scroll position; a missing/malformed ``blocks`` settles to the honest
|
||||
state instead of throwing."""
|
||||
js = _text(VERBATIM_JS)
|
||||
hydrate = _fn(js, "hydrateVerbatim")
|
||||
assert ".verbatim-pending[data-bor-locator]" in hydrate
|
||||
assert (
|
||||
"verbatim-unresolved"
|
||||
not in hydrate.split("matchVerbatimBlock")[1].split("replaceWith")[1].split("\n")[0]
|
||||
), "the unresolved node comes from its own builder"
|
||||
assert "replaceWith(" in hydrate
|
||||
assert "try {" not in hydrate, "the guards make it total — no exception path"
|
||||
assert "verbatimBlocks(blocks)" in hydrate
|
||||
guard = _fn(js, "verbatimBlocks")
|
||||
assert "Array.isArray(blocks)" in guard, "a missing frame key is not an error"
|
||||
|
||||
|
||||
def test_unlocated_blocks_land_in_the_answer_image_band() -> None:
|
||||
"""The blocks no placeholder matched (a ``show_text`` call the prose never
|
||||
named) append to ``.msg-body`` — the same band as the answer image, below
|
||||
the text and above the source clusters, in frame order — and a block
|
||||
already placed (a panel carrying its canonical locator) is never
|
||||
appended twice."""
|
||||
fn = _fn(_text(VERBATIM_JS), "appendUnlocatedVerbatim")
|
||||
assert 'wrap.querySelector(".msg-body")' in fn
|
||||
assert "host.appendChild(buildVerbatimPanel(block, opts))" in fn
|
||||
assert "placed.add(" in fn and "if (placed.has(key)) continue" in fn
|
||||
assert "verbatim-unresolved" in fn, (
|
||||
"an unresolved placeholder is NOT a placement (the block still belongs on screen)"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_settle_renders_the_honest_state() -> None:
|
||||
"""``settleVerbatimPlaceholders`` turns every leftover ``.verbatim-pending``
|
||||
into the unresolved node and records the ``kind`` for the next reader."""
|
||||
js = _text(VERBATIM_JS)
|
||||
fn = _fn(js, "settleVerbatimPlaceholders")
|
||||
assert 'root.querySelectorAll(".verbatim-pending")' in fn
|
||||
assert "buildVerbatimUnresolved(" in fn
|
||||
assert "replaceWith(" in fn, "the pending node is REPLACED, never patched"
|
||||
assert 'node.getAttribute("data-bor-locator")' in fn, (
|
||||
"the locator the model named rides into the honest state"
|
||||
)
|
||||
unresolved = _fn(js, "buildVerbatimUnresolved")
|
||||
assert 'root.dataset.verbatimState = (opts && opts.kind) || "unverified"' in unresolved, (
|
||||
"the settle kind is recorded for the next reader"
|
||||
)
|
||||
assert "state.textContent = verbatimUnresolvedCopy()" in unresolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSP + the house XSS rule (the new client, and the new code in the pages)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_inline_handlers_and_no_html_anywhere_in_the_client() -> None:
|
||||
"""CSP is ``default-src 'self'``: no ``onclick=``/``oncopy=`` attribute
|
||||
strings, no inline ``<script>``, no HTML string, no ``innerHTML`` (not
|
||||
even ``+=``) in ``verbatim.js`` — every node is ``createElement``, every
|
||||
value ``textContent``/``title``/a built ``href``, every handler
|
||||
``addEventListener``."""
|
||||
code = _strip_comments(_text(VERBATIM_JS))
|
||||
for marker in (
|
||||
"onclick=",
|
||||
"oncopy=",
|
||||
"onerror=",
|
||||
"onload=",
|
||||
".innerHTML",
|
||||
".outerHTML",
|
||||
"insertAdjacentHTML(",
|
||||
"document.write(",
|
||||
"eval(",
|
||||
"<script",
|
||||
):
|
||||
assert marker not in code, f"verbatim.js must stay HTML/handler-free: {marker}"
|
||||
assert "document.createElement(" in code
|
||||
assert code.count("document.createElement(") > 15, (
|
||||
"the panel is built node by node (the house rule)"
|
||||
)
|
||||
assert "addEventListener(" in code
|
||||
|
||||
|
||||
def test_the_pages_new_code_adds_no_inline_handler_or_inner_html() -> None:
|
||||
"""The task-07 additions to ``app.js`` / ``shared.js`` obey the same rule:
|
||||
no ``innerHTML +=`` anywhere, and the only ``onclick=``-shaped text in the
|
||||
two files stays inside their existing comments (none)."""
|
||||
for path in (APP_JS, SHARED_JS):
|
||||
js = _text(path)
|
||||
assert "innerHTML += " not in js, f"{path.name}: no innerHTML concatenation"
|
||||
assert "onclick=" not in js
|
||||
assert "oncopy=" not in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js: the live turn, the save point, the restore, the terminal paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _done_branch(js: str) -> str:
|
||||
start = js.index('ev.type === "done"')
|
||||
end = js.index('ev.type === "tool_result"', start)
|
||||
return js[start:end]
|
||||
|
||||
|
||||
def test_the_done_handler_hydrates_before_the_source_clusters() -> None:
|
||||
"""The order IS the layout: the answer image, then the hydrated panels,
|
||||
then the blocks the answer never located, then the Copy buttons on the
|
||||
settled code blocks, then the source clusters (phase-124/130 DOM order —
|
||||
the panels sit below the answer text and above the chips)."""
|
||||
branch = _done_branch(_text(APP_JS))
|
||||
for call in (
|
||||
"appendAnswerImage(wrap, ev.answer_image)",
|
||||
"hydrateVerbatim(",
|
||||
"appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS)",
|
||||
"enhanceAnswerCodeBlocks(wrap)",
|
||||
"appendSources(wrap, ev.sources)",
|
||||
):
|
||||
assert call in branch, f"the done handler must call {call}"
|
||||
order = [
|
||||
branch.index("appendAnswerImage("),
|
||||
branch.index("hydrateVerbatim("),
|
||||
branch.index("appendUnlocatedVerbatim("),
|
||||
branch.index("enhanceAnswerCodeBlocks("),
|
||||
branch.index("appendSources("),
|
||||
]
|
||||
assert order == sorted(order), "image → panels → unlocated → copy → sources"
|
||||
assert "VERBATIM_LINK_OPTS" in branch[branch.index("hydrateVerbatim(") :][:200]
|
||||
|
||||
|
||||
def test_the_answer_renderers_opt_in_and_nothing_else_does() -> None:
|
||||
"""The per-delta write and the ``done``-time re-render both pass
|
||||
``{ verbatim: true }`` (the third site is the restore renderer, pinned in
|
||||
``test_the_restore_path_rehydrates_from_the_record``); the user bubble of
|
||||
the same turn does NOT — a question is not a locator."""
|
||||
js = _text(APP_JS)
|
||||
branch = _done_branch(js)
|
||||
assert "renderMarkdown(acc, { verbatim: true })" in js
|
||||
assert "renderMarkdown(finalText, { verbatim: true })" in branch
|
||||
assert 'addMessage(\n "user",\n renderMarkdown(text),' in js, (
|
||||
"the sent question stays one-argument"
|
||||
)
|
||||
|
||||
|
||||
def test_the_save_point_persists_the_blocks_additively() -> None:
|
||||
"""``answer_text: ev.answer_text || undefined`` on the ``done`` save point
|
||||
— ``undefined`` drops the key from the JSON (the ``answer_image``
|
||||
precedent), so a pre-phase record stays byte-identical and a
|
||||
``answer_text=False`` turn stores nothing new."""
|
||||
branch = _done_branch(_text(APP_JS))
|
||||
assert "answer_text: ev.answer_text || undefined," in branch
|
||||
turn = _fn(_text(APP_JS), "rememberBrainTurn")
|
||||
assert "...meta" in turn, (
|
||||
"the meta spread carries the key — rememberBrainTurn itself is untouched"
|
||||
)
|
||||
|
||||
|
||||
def test_the_restore_path_rehydrates_from_the_record() -> None:
|
||||
"""Reload parity: the restore renderer opts in, then hydrates from
|
||||
``m.answer_text`` and appends the unlocated blocks in the same band, then
|
||||
settles the code blocks — the SAME builders as the live turn. A record
|
||||
with no ``answer_text`` renders its fences as the unresolved state (the
|
||||
record is what it is: never resurrect model bytes)."""
|
||||
js = _text(APP_JS)
|
||||
body = _fn(js, "renderStoredMessage")
|
||||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
|
||||
assert (
|
||||
'hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS)'
|
||||
in body
|
||||
)
|
||||
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS)" in body
|
||||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||||
order = [
|
||||
body.index("appendAnswerImage(wrap, m.answer_image)"),
|
||||
body.index("hydrateVerbatim("),
|
||||
body.index("appendUnlocatedVerbatim("),
|
||||
body.index("enhanceAnswerCodeBlocks("),
|
||||
body.index("appendSources(wrap, m.sources)"),
|
||||
]
|
||||
assert order == sorted(order)
|
||||
|
||||
|
||||
def test_every_terminal_path_settles_the_placeholders() -> None:
|
||||
"""Nothing sits at \u201cRetrieving the exact text\u2026\u201d after a terminal
|
||||
frame: the ``error`` frame handler, the 300 s guard, that guard's own
|
||||
abort surface, the user-stop branch and the failed-turn funnel each call
|
||||
``settleVerbatimPlaceholders(…, "unverified")`` — five sites, no more."""
|
||||
js = _text(APP_JS)
|
||||
assert js.count("settleVerbatimPlaceholders(") == 5, (
|
||||
"the five terminal sites: error frame, timeout, aborted catch, stop "
|
||||
"catch, failed-turn funnel"
|
||||
)
|
||||
assert js.count('"unverified"') >= 5, "every settle names the honest kind"
|
||||
funnel = _fn(js, "finalizeFailedTurn")
|
||||
assert 'settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");' in funnel
|
||||
assert funnel.index("closeAllRuns(wrap)") < funnel.index("settleVerbatimPlaceholders(")
|
||||
stop = js[js.index("stoppedByUser || err?.name ===") :]
|
||||
assert "settleVerbatimPlaceholders(" in stop[:800], "the Stop action settles"
|
||||
frame = js[js.index('ev.type === "error"') :]
|
||||
assert "settleVerbatimPlaceholders(" in frame[:700], "the error frame settles"
|
||||
|
||||
|
||||
def test_the_chat_panel_links_back_to_the_chat_page() -> None:
|
||||
"""The chat page's panels navigate EXACTLY like a source chip: the same
|
||||
``documentUrl(…, "/")`` href (back → the chat, phase 153) and the same
|
||||
left-click → same-page modal (phase 26), handed to the client as ONE
|
||||
options object."""
|
||||
js = _text(APP_JS)
|
||||
opts = (
|
||||
_fn(js, "VERBATIM_LINK_OPTS")
|
||||
if "function VERBATIM_LINK_OPTS" in js
|
||||
else js[
|
||||
js.index("const VERBATIM_LINK_OPTS") : js.index(
|
||||
"};", js.index("const VERBATIM_LINK_OPTS")
|
||||
)
|
||||
]
|
||||
)
|
||||
assert "documentHref:" in opts and 'documentUrl(source, path, "/")' in opts
|
||||
assert "onOpen:" in opts and 'openDocumentModal(source, path, opener, "/")' in opts
|
||||
# every app.js call site passes it
|
||||
assert _text(APP_JS).count("VERBATIM_LINK_OPTS") >= 5
|
||||
|
||||
|
||||
def test_the_share_path_delegates_to_the_one_clipboard_helper() -> None:
|
||||
"""Task 07 consolidates the clipboard path: ``app.js``'s owner-locked
|
||||
share copy delegates to ``copyTextWithFallback`` with the same host and
|
||||
the same title, and the page no longer carries its own selection
|
||||
helper."""
|
||||
js = _text(APP_JS)
|
||||
share = _fn(js, "copyShareLinkWithFallback")
|
||||
assert "copyTextWithFallback(" in share
|
||||
assert "composer," in share
|
||||
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in share
|
||||
assert "function selectAllInField" not in js, (
|
||||
"the selection helper moved with the clipboard path — one implementation"
|
||||
)
|
||||
verbatim = _text(VERBATIM_JS)
|
||||
assert "function selectAllInFallbackField" in verbatim
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# shared.js parity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_page_reaches_the_same_globals_with_a_plain_link() -> None:
|
||||
"""Parity is a hard requirement: the shared renderer calls the SAME three
|
||||
globals in the SAME band and order as the chat page's restore, and passes
|
||||
a plain ``/document.html?source=&path=`` href with NO modal (that page has
|
||||
none, and the documents API is admin-gated)."""
|
||||
js = _text(SHARED_JS)
|
||||
body = _fn(js, "renderSharedMessage")
|
||||
assert "hydrateVerbatim(" in body
|
||||
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS)" in body
|
||||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||||
order = [
|
||||
body.index("addAnswerImage(wrap, m.answer_image)"),
|
||||
body.index("hydrateVerbatim("),
|
||||
body.index("appendUnlocatedVerbatim("),
|
||||
body.index("addSources(wrap, m.sources)"),
|
||||
]
|
||||
assert order == sorted(order)
|
||||
opts = js[
|
||||
js.index("const SHARED_VERBATIM_OPTS") : js.index(
|
||||
"};", js.index("const SHARED_VERBATIM_OPTS")
|
||||
)
|
||||
]
|
||||
assert "/document.html?source=" in opts and "encodeURIComponent(" in opts
|
||||
assert "onOpen" not in opts, "no modal on the shared page"
|
||||
assert "openDocumentModal" not in js
|
||||
assert "document-modal" not in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# styles.css: the states the JS adds stay inside the phase-182 block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _css_block() -> str:
|
||||
css = _text(STYLES_CSS)
|
||||
start = css.index(BLOCK_BEGIN)
|
||||
end = css.index(BLOCK_END, start)
|
||||
return css[start : end + len(BLOCK_END)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selector", [".verbatim-body", ".verbatim-text", ".verbatim-open", ".verbatim-more"]
|
||||
)
|
||||
def test_the_task_07_dom_selectors_are_declared_in_the_phase_182_block(
|
||||
selector: str,
|
||||
) -> None:
|
||||
"""The DOM the panel builder really produces is styled INSIDE the labelled
|
||||
region (so task 06's palette-only and contiguity pins keep holding): the
|
||||
body is the flex row the gutter's ``flex: 0 0 auto`` was written for, and
|
||||
the viewer link + the truncation line are themed, not unstyled."""
|
||||
block = _css_block()
|
||||
rules = re.findall(r"(?m)^" + re.escape(selector) + r" \{([^}]*)\}", block)
|
||||
assert rules, f"{selector} must be declared inside the phase-182 block"
|
||||
# task 06's `_body` idiom: task 07 extends a rule with its own declaration
|
||||
# line (a second block for the same selector), so every body counts.
|
||||
body = " ".join(rules)
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), "no hex colour"
|
||||
for literal in ("rgb(", "rgba(", "hsl(", "hsla("):
|
||||
assert literal not in body, f"{selector} must stay palette-only ({literal})"
|
||||
assert not re.search(r":\s*(?:white|black|#fff|#000)\s*[;}]", body), (
|
||||
f"{selector} must not name a colour"
|
||||
)
|
||||
if selector == ".verbatim-body":
|
||||
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
|
||||
if selector == ".verbatim-body":
|
||||
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
|
||||
if selector == ".verbatim-open":
|
||||
assert "text-decoration: underline" in body, "a link is not colour-only"
|
||||
assert "var(--ink-soft)" in body, "the link is themed, not unstyled"
|
||||
if selector == ".verbatim-more":
|
||||
assert "var(--ink-soft)" in body and "var(--line)" in body
|
||||
|
||||
|
||||
def test_the_viewer_link_reaches_the_mobile_touch_floor() -> None:
|
||||
"""PLAN §7's 44px tap target applies to the panel's link too — it lives in
|
||||
the ``max-width: 640px`` block beside the two Copy controls."""
|
||||
css = _text(STYLES_CSS)
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "the 640px block must exist"
|
||||
hit = re.search(r"\.verbatim-open \{([^}]*)\}", mobile.group(1))
|
||||
assert hit and "min-height: 44px" in hit.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# node: the panel's pure logic, run against the real file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_HARNESS = """
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const api = new Function(
|
||||
src +
|
||||
"\\nreturn { verbatimCaptionText, verbatimGutter, verbatimPanelLabel," +
|
||||
" verbatimIdentity, verbatimSplitLocator, verbatimDocumentHref," +
|
||||
" isVerbatimBlock, verbatimBlocks, matchVerbatimBlock, verbatimUnresolvedCopy," +
|
||||
" verbatimBadgeTitle };"
|
||||
)();
|
||||
const cases = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
|
||||
const out = {};
|
||||
for (const [name, body] of Object.entries(cases)) {
|
||||
try {
|
||||
// `with` so the case bodies read the panel API as plain names (the file
|
||||
// itself is sloppy-mode classic-script code, and the harness is its
|
||||
// own scope — no strict-mode conflict).
|
||||
const fn = new Function("api", "with (api) { return (" + body + "); }");
|
||||
out[name] = { ok: true, value: fn(api) };
|
||||
} catch (err) {
|
||||
out[name] = { ok: false, error: String(err && err.message ? err.message : err) };
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tmp_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
return tmp_path_factory.mktemp("verbatim")
|
||||
|
||||
|
||||
def _node(cases: dict[str, str], tmp_dir: Path) -> dict[str, object]:
|
||||
"""Run *cases* (JS bodies receiving the panel API) under node against the
|
||||
real ``verbatim.js`` — the pure half needs no DOM."""
|
||||
script = tmp_dir / "harness.cjs"
|
||||
script.write_text(_HARNESS, encoding="utf-8")
|
||||
case_file = tmp_dir / "cases.json"
|
||||
case_file.write_text(
|
||||
# the case bodies are plain JS EXPRESSIONS evaluated with the panel
|
||||
# API in scope (the harness wraps them, see _HARNESS)
|
||||
json.dumps(dict(cases)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(script), str(VERBATIM_JS), str(case_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_caption_prints_the_servers_extent(tmp_dir: Path) -> None:
|
||||
"""The caption is the SERVER's resolved extent (LOCKED A2 made visible):
|
||||
a slice reads ``path · lines 12\u201340 of 210``, a whole document reads
|
||||
``path · all 210 lines``, a CAPPED block is never labelled "all" even when
|
||||
it starts at line 1, and a garbage count degrades to the bare path rather
|
||||
than printing a lie."""
|
||||
out = _node(
|
||||
{
|
||||
"slice": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 12, end_line: 40, total_lines: 210 })""",
|
||||
"whole": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 1, end_line: 210, total_lines: 210 })""",
|
||||
"capped": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 1, end_line: 30, total_lines: 210, truncated: true })""",
|
||||
"no_total": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: 3, end_line: 4 })""",
|
||||
"garbage": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||||
start_line: "x", end_line: null })""",
|
||||
"label": """verbatimPanelLabel({ source: "homelab", path: "a.md",
|
||||
start_line: 12, end_line: 40 })""",
|
||||
"label_whole": """verbatimPanelLabel({ source: "homelab", path: "a.md",
|
||||
start_line: 1, end_line: 1 })""",
|
||||
"href": """verbatimDocumentHref("src a", "dir/x y.md")""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["slice"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 12\u201340 of 210"}
|
||||
assert out["whole"] == {"ok": True, "value": "homelab/a.md \u00b7 all 210 lines"}
|
||||
assert out["capped"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 1\u201330 of 210"}
|
||||
assert out["no_total"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 3\u20134"}
|
||||
assert out["garbage"] == {"ok": True, "value": "homelab/a.md"}
|
||||
assert out["label"] == {
|
||||
"ok": True,
|
||||
"value": "Verbatim text from homelab/a.md, lines 12\u201340",
|
||||
}
|
||||
assert out["label_whole"] == {
|
||||
"ok": True,
|
||||
"value": "Verbatim text from homelab/a.md, line 1",
|
||||
}, "one line reads `line N`, never `lines 1–1`"
|
||||
# SEC: the href is built from ENCODED halves (the `documentUrl` parity —
|
||||
# the whole path is one query value, slashes included, and FastAPI decodes
|
||||
# it back), and it is always a same-origin relative URL.
|
||||
assert out["href"] == {
|
||||
"ok": True,
|
||||
"value": "/document.html?source=src%20a&path=dir%2Fx%20y.md",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_gutter_numbers_the_servers_lines_and_not_the_marker(tmp_dir: Path) -> None:
|
||||
"""One gutter row per line of the server text (so the rows align with the
|
||||
``<pre>``), numbered from the server's ``start_line``. The truncation
|
||||
marker the cap appended is a real line of the panel but NOT a line of the
|
||||
file, so it gets a blank cell (an empty cell keeps the rows aligned \u2014 the
|
||||
gutter never claims a coordinate the file does not have)."""
|
||||
out = _node(
|
||||
{
|
||||
"range": """verbatimGutter({ text: "one\\ntwo\\nthree", start_line: 12,
|
||||
end_line: 14 }).map(r => r.number).join("|")""",
|
||||
"truncated": """verbatimGutter({ text: "one\\ntwo\\n\\u2026truncated\\u2026",
|
||||
start_line: 1, end_line: 2, truncated: true })
|
||||
.map(r => JSON.stringify(r.number)).join("|")""",
|
||||
"unnumbered": """verbatimGutter({ text: "a\\nb" }).map(r => r.number).join("|")""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["range"] == {"ok": True, "value": "12|13|14"}
|
||||
assert out["truncated"] == {"ok": True, "value": '"1"|"2"|" "'}
|
||||
assert out["unnumbered"] == {"ok": True, "value": " | "}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_match_rule_exact_then_unique_path_then_none(tmp_dir: Path) -> None:
|
||||
"""The ONE hydration rule: the canonical locator first; else exactly ONE
|
||||
block for the same ``source/path`` (the model's range was wrong \u2014 the panel
|
||||
shows the server's extent); else null, INCLUDING the ambiguous case (two
|
||||
blocks, same file, different ranges \u2014 never guess)."""
|
||||
out = _node(
|
||||
{
|
||||
"exact": """(() => { const b = [
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#12-40" },
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#1-5" }];
|
||||
return matchVerbatimBlock(b, "h/a.md#1-5").locator; })()""",
|
||||
"path_only": """(() => { const b = [{ source: "h", path: "a.md",
|
||||
locator: "h/a.md#1-9" }];
|
||||
return matchVerbatimBlock(b, "h/a.md#99-120").locator; })()""",
|
||||
"whole_doc_path_only": """(() => { const b = [{ source: "h", path: "a.md",
|
||||
locator: "h/a.md" }];
|
||||
return matchVerbatimBlock(b, "h/a.md").locator; })()""",
|
||||
"ambiguous": """(() => { const b = [
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#1-9" },
|
||||
{ source: "h", path: "a.md", locator: "h/a.md#20-29" }];
|
||||
return matchVerbatimBlock(b, "h/a.md#7-8"); })()""",
|
||||
"none": """matchVerbatimBlock([], "h/a.md#1-2")""",
|
||||
"other_path": """matchVerbatimBlock([{ source: "h", path: "b.md",
|
||||
locator: "h/b.md" }], "h/a.md")""",
|
||||
"split": """JSON.stringify(verbatimSplitLocator("h/a.md#12-40"))""",
|
||||
"split_no_range": """JSON.stringify(verbatimSplitLocator("h/a.md"))""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["exact"] == {"ok": True, "value": "h/a.md#1-5"}
|
||||
assert out["path_only"] == {"ok": True, "value": "h/a.md#1-9"}
|
||||
assert out["whole_doc_path_only"] == {"ok": True, "value": "h/a.md"}
|
||||
assert out["ambiguous"] == {"ok": True, "value": None}
|
||||
assert out["none"] == {"ok": True, "value": None}
|
||||
assert out["other_path"] == {"ok": True, "value": None}
|
||||
assert out["split"] == {
|
||||
"ok": True,
|
||||
"value": '{"identity":"h/a.md","hasRange":true}',
|
||||
}
|
||||
assert out["split_no_range"] == {
|
||||
"ok": True,
|
||||
"value": '{"identity":"h/a.md","hasRange":false}',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_malformed_blocks_are_dropped_and_never_throw(tmp_dir: Path) -> None:
|
||||
"""A malformed / missing ``answer_text`` is a NON-event: every odd shape
|
||||
(null, a bare object, a block without text, a block without a path,
|
||||
numbers-as-strings) is filtered out instead of throwing \u2014 hydration stays
|
||||
honest for the placeholders instead of painting garbage."""
|
||||
out = _node(
|
||||
{
|
||||
"undefined_blocks": """verbatimBlocks(undefined).length""",
|
||||
"not_an_array": """verbatimBlocks({ length: 1, 0: { source: "h", path: "a.md",
|
||||
text: "x" } }).length""",
|
||||
"mixed": """verbatimBlocks([null, 7, "s", {}, { source: "", path: "a.md", text: "x" },
|
||||
{ source: "h", path: "", text: "x" },
|
||||
{ source: "h", path: "a.md" },
|
||||
{ source: "h", path: "a.md", text: "" }]).length""",
|
||||
"identity_with_empty_text": """isVerbatimBlock({ source: "h",
|
||||
path: "a.md", text: "" })""",
|
||||
"identity": """verbatimIdentity("homelab", "runbooks/a.md")""",
|
||||
"unresolved_copy": """verbatimUnresolvedCopy()""",
|
||||
"badge_title": """verbatimBadgeTitle()""",
|
||||
},
|
||||
tmp_dir,
|
||||
)
|
||||
assert out["undefined_blocks"] == {"ok": True, "value": 0}
|
||||
assert out["not_an_array"] == {"ok": True, "value": 0}
|
||||
# only the last entry survives: the others miss source/path/text.
|
||||
assert out["mixed"] == {"ok": True, "value": 1}
|
||||
assert out["identity_with_empty_text"] == {"ok": True, "value": True}
|
||||
assert out["identity"] == {"ok": True, "value": "homelab/runbooks/a.md"}
|
||||
assert out["unresolved_copy"] == {"ok": True, "value": UNRESOLVED_COPY}
|
||||
assert out["badge_title"] == {"ok": True, "value": BADGE_TITLE}
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Unit: the phase-182 verbatim-panel stylesheet contract (task 06).
|
||||
|
||||
The panel DOM is built by ``verbatim.js`` (task 07) and its streaming
|
||||
placeholder by ``markdown.js`` (task 06); this module pins the CSS contract
|
||||
that makes the phase readable, in the ``test_frontend_cluster_css.py`` /
|
||||
``test_markdown_inline_links_images.py`` house pattern (no browser — the
|
||||
browser-level evidence is the phase's Playwright suite, task 08):
|
||||
|
||||
* the phase-182 block EXISTS as one clearly-labelled region of
|
||||
``frontend/assets/styles.css`` (BEGIN/END markers) and every selector the
|
||||
contract names is styled there — ``.verbatim``, ``.verbatim-head``,
|
||||
``.verbatim-caption``, ``.verbatim-badge``, ``.verbatim-body``,
|
||||
``.verbatim-lines``, ``.verbatim-pending``, ``.verbatim-state``,
|
||||
``.verbatim-unresolved``, ``.code-block``, ``.code-block-copy``,
|
||||
``.verbatim-copy``;
|
||||
* the panel is a BOUNDED scroll panel (PLAN §7.1): ``max-height`` in the
|
||||
``min(…, 70vh)`` form + ``overflow: auto``, with the head row STICKY
|
||||
(``position: sticky; top: 0``) on an opaque background so the caption
|
||||
never scrolls away and rows never show through it;
|
||||
* the code surface never wraps (phase 116 locked A1, the phase-165 prose/code
|
||||
split): ``white-space: pre`` + ``overflow-wrap: normal`` + ``tab-size: 2``,
|
||||
and the line-number gutter is ``user-select: none`` so a Copy/select never
|
||||
drags numbers into the clipboard;
|
||||
* text + color, never color alone (WCAG 1.4.1): the badge is a TEXT badge
|
||||
with ``text-transform: none``, the unresolved state hides the badge, and
|
||||
the ``.copied`` state exists beside the text swap task 07 performs;
|
||||
* the whole block is PALETTE-VARIABLES ONLY (the phase-92 zero-literal /
|
||||
monochrome invariant): no hex/rgb/hsl literal anywhere, and every
|
||||
``var()`` is a declared ``:root`` token — so a light theme keeps this code
|
||||
surface readable and no new hue enters the system;
|
||||
* the ≤640px block lifts the Copy controls to the 44px touch floor, and
|
||||
nothing in the block animates (so ``prefers-reduced-motion`` has nothing
|
||||
to still — the rule is written as a guard so a future animation cannot be
|
||||
added without its still).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
||||
|
||||
#: The phase-182 region markers (the block is one contiguous, labelled
|
||||
#: region, so "no literals in the phase-182 block" is a parseable claim).
|
||||
BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
|
||||
END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
|
||||
|
||||
#: Every selector the task's contract names.
|
||||
PANEL_SELECTORS = (
|
||||
".verbatim",
|
||||
".verbatim-head",
|
||||
".verbatim-caption",
|
||||
".verbatim-badge",
|
||||
".verbatim-body",
|
||||
".verbatim-lines",
|
||||
".verbatim-pending",
|
||||
".verbatim-state",
|
||||
".verbatim-unresolved",
|
||||
".code-block",
|
||||
".code-block-copy",
|
||||
".verbatim-copy",
|
||||
)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _phase182_block(css: str) -> str:
|
||||
"""The phase-182 region, markers included (comments intact — the block
|
||||
carries the contract in prose too)."""
|
||||
start = css.find(BEGIN)
|
||||
assert start != -1, "styles.css must carry the labelled phase-182 block (BEGIN marker)"
|
||||
end = css.find(END, start)
|
||||
assert end != -1, "the phase-182 block must be closed by its END marker"
|
||||
return css[start : end + len(END)]
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
return re.sub(r"/\*[\s\S]*?\*/", "", css)
|
||||
|
||||
|
||||
def _rule_bodies(block: str, selector: str) -> list[str]:
|
||||
"""The bodies of every rule whose selector list contains ``selector``
|
||||
EXACTLY (the phase-180 parse pattern, comment-stripped)."""
|
||||
bodies: list[str] = []
|
||||
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", _strip_comments(block)):
|
||||
selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")]
|
||||
if selector in selectors:
|
||||
bodies.append(match.group(2))
|
||||
return bodies
|
||||
|
||||
|
||||
def _body(block: str, selector: str) -> str:
|
||||
bodies = _rule_bodies(block, selector)
|
||||
assert bodies, f"styles.css must style {selector}"
|
||||
return " ".join(bodies)
|
||||
|
||||
|
||||
def _root_palette(css: str) -> set[str]:
|
||||
"""Every variable name the ``:root`` block declares."""
|
||||
root = re.search(r":root\s*\{", css)
|
||||
assert root, "styles.css must define :root"
|
||||
body = css[root.end() : css.find("}", root.end())]
|
||||
palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body))
|
||||
assert palette, "the :root palette must declare variables"
|
||||
return palette
|
||||
|
||||
|
||||
def _media_block(css: str, query: str) -> str:
|
||||
"""The body of the ``@media (<query>)`` block — the house non-greedy
|
||||
pattern (nested closing braces are indented, so the first column-0 ``}``
|
||||
is the block's own end)."""
|
||||
m = re.search("@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}", css)
|
||||
assert m, f"{query} media query missing"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
_CSS = _css()
|
||||
_BLOCK = _phase182_block(_CSS)
|
||||
_ROOT = _root_palette(_CSS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the block and its selectors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_phase_182_block_is_labelled_and_contains_every_selector() -> None:
|
||||
"""The block is ONE clearly-labelled region carrying every selector of
|
||||
the contract (task 06 item 5) — a rule that drifted outside the region
|
||||
would escape the palette-only and reduced-motion pins below."""
|
||||
for selector in PANEL_SELECTORS:
|
||||
assert _rule_bodies(_BLOCK, selector), f"the phase-182 block must style {selector}"
|
||||
# …and the block says what it is.
|
||||
assert "phase 182" in _BLOCK
|
||||
assert "VERBATIM" in _BLOCK, "the block documents the badge it styles"
|
||||
|
||||
|
||||
def test_panel_is_a_themed_surface_card() -> None:
|
||||
"""``.verbatim`` is a block on the theme's own canvas: the bubble's
|
||||
rhythm (a vertical margin), a --line hairline, --radius-sm corners, the
|
||||
--surface fill, ``min-width: 0`` (the long-line escape hatch every
|
||||
scroller here carries) and ``overflow: hidden`` so the rounded corners
|
||||
clip the scrolled body."""
|
||||
body = _body(_BLOCK, ".verbatim")
|
||||
for prop in (
|
||||
"display: block",
|
||||
"min-width: 0",
|
||||
"background: var(--surface)",
|
||||
"border: 1px solid var(--line)",
|
||||
"border-radius: var(--radius-sm)",
|
||||
"overflow: hidden",
|
||||
):
|
||||
assert prop in body, f".verbatim must carry {prop}"
|
||||
assert re.search(r"margin:\s0\.[0-9]+rem 0;", body), (
|
||||
".verbatim sits in the bubble's rhythm (a rem margin, not a px one)"
|
||||
)
|
||||
|
||||
|
||||
def test_head_row_is_sticky_with_an_opaque_background() -> None:
|
||||
"""PLAN §7.1's bounded-panel rule: the head row STICKS to the top of the
|
||||
scroll panel, so the caption (``source/path · lines 12–40 of 210``) is
|
||||
still on screen in a 40-line slice. OPAQUE ``--surface`` background — a
|
||||
translucent head would let the bytes read through it (the phase-156
|
||||
``.table-wrap table thead th`` idiom)."""
|
||||
body = _body(_BLOCK, ".verbatim-head")
|
||||
assert "position: sticky" in body, "the head row must be sticky"
|
||||
assert "top: 0" in body, "the sticky offset is the panel top"
|
||||
assert "background: var(--surface)" in body, "the sticky head must be opaque"
|
||||
assert "z-index: 1" in body, "the sticky head paints above the scrolled body"
|
||||
assert "display: flex" in body, "the head is a row (caption, badge, copy)"
|
||||
|
||||
|
||||
def test_caption_is_mono_soft_and_clipped_the_house_way() -> None:
|
||||
"""``.verbatim-caption`` uses the phase-156 ``.cell-clip`` ellipsis
|
||||
convention (``min-width: 0`` + ``overflow: hidden`` +
|
||||
``text-overflow: ellipsis`` + ``white-space: nowrap``) so a long
|
||||
``source/path`` clips to ``…`` instead of breaking the head row — the
|
||||
FULL text stays reachable through the control's ``aria-label``/``title``
|
||||
(the clip is visual only). ``--ink-soft`` on ``--surface`` is 8.2:1
|
||||
(AA), and the mono family matches every other path in the app."""
|
||||
body = _body(_BLOCK, ".verbatim-caption")
|
||||
for prop in (
|
||||
"min-width: 0",
|
||||
"overflow: hidden",
|
||||
"text-overflow: ellipsis",
|
||||
"white-space: nowrap",
|
||||
"font-family: var(--mono)",
|
||||
"color: var(--ink-soft)",
|
||||
):
|
||||
assert prop in body, f".verbatim-caption must carry {prop}"
|
||||
# the ellipsis convention must not rely on a fixed max-width here: the
|
||||
# caption is the flexible item of a wrapping flex row.
|
||||
assert "flex: 1" in body, "the caption takes the row's flexible space"
|
||||
|
||||
|
||||
def test_badge_is_text_with_no_uppercase_surprise() -> None:
|
||||
"""The badge is the WORD ``VERBATIM`` in the --ok family (10.6:1 on its
|
||||
own fill) with ``text-transform: none`` — an uppercase-forced word is
|
||||
spelled out letter-by-letter by some screen readers, and an icon-only
|
||||
badge would fail WCAG 1.4.1 (the claim must be TEXT)."""
|
||||
body = _body(_BLOCK, ".verbatim-badge")
|
||||
assert "text-transform: none" in body
|
||||
assert "color: var(--ok-ink)" in body, "the badge is the --ok family"
|
||||
assert "background: var(--ok-bg)" in body
|
||||
assert "content:" not in body, "the badge text is DOM text, never a CSS ::before"
|
||||
|
||||
|
||||
def test_body_is_a_bounded_scroll_panel_of_code() -> None:
|
||||
"""``.verbatim-body`` is the bounded scroll panel: ``max-height`` in the
|
||||
``min(…, 70vh)`` form (PLAN §7.1 — the panel never eats the page),
|
||||
``overflow: auto`` both axes, the mono family, ``white-space: pre`` and
|
||||
``tab-size: 2`` (the file's own indentation survives), and
|
||||
``overflow-wrap: normal`` — CODE scrolls horizontally and prose never
|
||||
does (phase 116 locked A1, the phase-165 split), which also cancels the
|
||||
``.bubble``'s inherited ``overflow-wrap: anywhere``. ``min-width: 0``
|
||||
keeps the long-line escape hatch."""
|
||||
body = _body(_BLOCK, ".verbatim-body")
|
||||
assert re.search(r"max-height:\s*min\(\s*[0-9.]+rem,\s*70vh\s*\)", body), (
|
||||
".verbatim-body must cap with max-height: min(<rem>, 70vh)"
|
||||
)
|
||||
for prop in (
|
||||
"overflow: auto",
|
||||
"min-width: 0",
|
||||
"font-family: var(--mono)",
|
||||
"white-space: pre",
|
||||
"tab-size: 2",
|
||||
"overflow-wrap: normal",
|
||||
):
|
||||
assert prop in body, f".verbatim-body must carry {prop}"
|
||||
assert "white-space: pre-wrap" not in body, "code never wraps (locked A1)"
|
||||
|
||||
|
||||
def test_line_numbers_are_not_selectable() -> None:
|
||||
"""The gutter is ``user-select: none`` (with the ``-webkit-`` prefix) on
|
||||
BOTH the container and its items, right-aligned in ``--ink-soft``: a
|
||||
reader who selects the panel — or a Copy path that reads the selection —
|
||||
never drags line numbers into the clipboard. ``list-style: none`` means
|
||||
the digits are TEXT, so the rule behaves identically in every engine."""
|
||||
container = _body(_BLOCK, ".verbatim-lines")
|
||||
for prop in (
|
||||
"user-select: none",
|
||||
"-webkit-user-select: none",
|
||||
"text-align: right",
|
||||
"color: var(--ink-soft)",
|
||||
"list-style: none",
|
||||
):
|
||||
assert prop in container, f".verbatim-lines must carry {prop}"
|
||||
items = _body(_BLOCK, ".verbatim-lines li")
|
||||
assert "user-select: none" in items, "the <li> line numbers must be unselectable too"
|
||||
|
||||
|
||||
def test_pending_state_is_muted_and_static() -> None:
|
||||
"""The streaming state (``.verbatim-pending`` + ``.verbatim-state``, the
|
||||
markdown.js placeholder) is MUTED — ``--ink-soft`` on ``--surface``,
|
||||
8.2:1 — and it never announces itself with motion: the pending line is a
|
||||
``role="status"`` live region, so the announcement IS the feedback."""
|
||||
pending = _body(_BLOCK, ".verbatim-pending")
|
||||
state = _body(_BLOCK, ".verbatim-state")
|
||||
assert "color: var(--ink-soft)" in state, "the pending line is muted (not an answer)"
|
||||
assert "background: var(--surface)" in pending
|
||||
assert "animation" not in pending and "transition" not in pending
|
||||
assert "animation" not in state and "transition" not in state
|
||||
|
||||
|
||||
def test_unresolved_state_carries_no_badge() -> None:
|
||||
"""The honest failure state: the --err boundary + --err-bg canvas with
|
||||
--ink-soft text (7.9:1, AA), and CSS-side enforcement that a ``VERBATIM``
|
||||
badge NEVER appears on an unresolved panel whatever DOM reaches it — the
|
||||
badge belongs only to a server-resolved block (locked A5)."""
|
||||
body = _body(_BLOCK, ".verbatim-unresolved")
|
||||
assert "var(--err" in body, "the unresolved state is the --err family"
|
||||
assert "color: var(--ink-soft)" in body
|
||||
hide = _body(_BLOCK, ".verbatim-unresolved .verbatim-badge")
|
||||
assert "display: none" in hide, "an unresolved panel never badges"
|
||||
|
||||
|
||||
def test_copy_controls_are_pills_with_a_ring_and_an_ellipsis_label() -> None:
|
||||
"""``.code-block-copy`` / ``.verbatim-copy``: the house pill (mono text,
|
||||
--line hairline, --surface fill, ink-soft label), the label is the clip
|
||||
target (``text-overflow: ellipsis`` on its span, the control's
|
||||
``aria-label`` naming the full path), the ``:focus-visible`` ring is the
|
||||
app's 3px --brand ring, and the ``.copied`` rule exists to SUPPORT the
|
||||
text swap (``Copy`` → ``Copied``) with colour — never colour alone."""
|
||||
both = _body(_BLOCK, ".code-block-copy") + " " + _body(_BLOCK, ".verbatim-copy")
|
||||
for prop in (
|
||||
"border: 1px solid var(--line)",
|
||||
"background: var(--surface)",
|
||||
"color: var(--ink-soft)",
|
||||
"font-family: var(--mono)",
|
||||
"cursor: pointer",
|
||||
"min-height:",
|
||||
):
|
||||
assert prop in both, f"the copy controls must carry {prop}"
|
||||
label = _body(_BLOCK, ".code-block-copy > span")
|
||||
assert "text-overflow: ellipsis" in label, "a clipped copy label shows the hint"
|
||||
for selector in (".code-block-copy:focus-visible", ".verbatim-copy:focus-visible"):
|
||||
ring = _body(_BLOCK, selector)
|
||||
assert "outline: 3px solid var(--brand)" in ring, f"{selector} needs the house ring"
|
||||
assert "outline-offset: 2px" in ring
|
||||
for selector in (".code-block-copy.copied", ".verbatim-copy.copied"):
|
||||
copied = _body(_BLOCK, selector)
|
||||
assert "var(--ok" in copied, f"{selector} must use the --ok family as SUPPORT"
|
||||
|
||||
|
||||
def test_plain_code_block_wrapper_is_not_a_verbatim_panel() -> None:
|
||||
"""``.code-block`` is the plain wrapper task 07 puts around an ordinary
|
||||
answer ``<pre>``: a Copy button belongs to it, the ``VERBATIM`` badge
|
||||
does NOT — so the wrapper rule carries no --ok fill and no panel chrome
|
||||
(locked A5: verbatim-and-badged OR plainly the model's, never a hybrid)."""
|
||||
body = _body(_BLOCK, ".code-block")
|
||||
assert "position: relative" in body
|
||||
assert "min-width: 0" in body
|
||||
assert "--ok" not in body, "an ordinary code block never wears the verbatim badge"
|
||||
assert not _rule_bodies(_BLOCK, ".code-block .verbatim-badge"), (
|
||||
"no badge rule may exist inside the plain wrapper"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the palette-only invariant (phase 92 / the :root-drift guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_phase_182_block_uses_palette_variables_only() -> None:
|
||||
"""NO colour literal anywhere in the phase-182 block: every colour comes
|
||||
from a declared ``:root`` token (no hex, no ``rgb()``/``rgba()``, no
|
||||
``hsl()``, no named colour). A literal would be a second source of truth
|
||||
for the built-in palette (the ``test_theming.py`` drift guard parses the
|
||||
built-ins out of this file) and a new hue would break the monochrome
|
||||
invariant — this is also what keeps a code surface readable when the
|
||||
owner switches the app to a light or a monochrome theme."""
|
||||
code = _strip_comments(_BLOCK)
|
||||
refs = re.findall(r"var\((--[a-z0-9-]+)\)", code)
|
||||
assert refs, "the phase-182 block must reference palette vars"
|
||||
for ref in set(refs):
|
||||
assert ref in _ROOT, f"{ref} is not a :root palette var"
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", code), "no hex color literal in the block"
|
||||
for fn in ("rgb(", "rgba(", "hsl(", "hsla("):
|
||||
assert fn not in code, f"no {fn} color literal in the block"
|
||||
# named colours are the other way a second source of truth sneaks in
|
||||
for name in (": white", ": black", "white;", "black;", "white }", "black }"):
|
||||
assert name not in code, f"no named colour in the block ({name.strip()})"
|
||||
|
||||
|
||||
def test_phase_182_block_restates_no_global_focus_rule_wrongly() -> None:
|
||||
"""The restatement of the global ``:focus-visible`` ring uses the SAME
|
||||
values as the app-wide rule (3px --brand, 2px offset) — a different
|
||||
ring here would be a second, drifting source of focus truth."""
|
||||
global_rule = _css()[: _css().find("\n::selection")]
|
||||
assert re.search(r":focus-visible \{\s*outline: 3px solid var\(--brand\);\s*"
|
||||
r"outline-offset: 2px;", global_rule), (
|
||||
"the app-wide :focus-visible rule is the baseline this block copies"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mobile + reduced motion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mobile_block_keeps_a_44px_control() -> None:
|
||||
"""The ≤640px block lifts BOTH Copy controls to the 44px touch floor
|
||||
(PLAN §7: the phone tap target) — the desktop pill is smaller, so the
|
||||
rule must live in the media block, not only in the base rule."""
|
||||
mobile = _media_block(_CSS, "max-width: 640px")
|
||||
assert "Phase 182" in mobile, "the mobile block must name the phase it serves"
|
||||
hit = re.search(r"\.code-block-copy, \.verbatim-copy \{([^}]*)\}", mobile)
|
||||
assert hit, "the mobile block must raise both Copy controls together"
|
||||
assert "min-height: 44px" in hit.group(1)
|
||||
|
||||
|
||||
def test_nothing_in_the_block_animates_without_a_reduced_motion_still() -> None:
|
||||
"""``prefers-reduced-motion`` is honored by anything that animates. The
|
||||
phase-182 block animates NOTHING (the pending state is a live region, so
|
||||
the text swap is the feedback), which this pins as a GUARD: if a future
|
||||
edit adds an ``animation``/``transition``, the same selectors must appear
|
||||
in a ``prefers-reduced-motion: reduce`` block that stills them."""
|
||||
code = _strip_comments(_BLOCK)
|
||||
animated: list[str] = []
|
||||
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", code):
|
||||
body = match.group(2)
|
||||
if re.search(r"(^|[;\s])(animation|transition)\s*:", body):
|
||||
animated.append(" ".join(match.group(1).split()))
|
||||
if not animated:
|
||||
assert "prefers-reduced-motion" in _BLOCK, (
|
||||
"a static block must say so, so the next reader does not add "
|
||||
"a pulse 'to match the app'"
|
||||
)
|
||||
return
|
||||
reduced = "\n".join(
|
||||
m.group(0)
|
||||
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\)[\s\S]*?\n\}", _CSS)
|
||||
)
|
||||
for selectors in animated:
|
||||
for selector in selectors.split(","):
|
||||
assert selector.strip() in reduced, (
|
||||
f"{selector} animates but is never stilled under "
|
||||
"prefers-reduced-motion"
|
||||
)
|
||||
|
||||
|
||||
def test_the_panel_never_repaints_the_existing_code_and_bubble_rules() -> None:
|
||||
"""The neighbours are untouched: ``.bubble pre`` (the ordinary answer
|
||||
code block) and ``.doc-raw`` (the viewer's scroller) keep their
|
||||
declarations, so the phase adds a SURFACE and does not restyle every
|
||||
code block in the app."""
|
||||
css = _CSS
|
||||
pre = re.search(r"\.bubble pre \{([^}]*)\}", css)
|
||||
assert pre, ".bubble pre must still exist"
|
||||
for prop in ("background: var(--surface)", "border: 1px solid var(--line)", "overflow-x: auto"):
|
||||
assert prop in pre.group(1), f".bubble pre must keep {prop}"
|
||||
raw = re.search(r"\.doc-raw \{([^}]*)\}", css)
|
||||
assert raw, ".doc-raw must still exist"
|
||||
assert "white-space: pre" in raw.group(1)
|
||||
|
||||
|
||||
def test_block_is_one_contiguous_region_of_the_sheet() -> None:
|
||||
"""The BEGIN/END markers wrap ONE region (a phase that appends a stray
|
||||
``.verbatim-*`` rule elsewhere escapes every pin in this module)."""
|
||||
css = _CSS
|
||||
assert css.count(BEGIN) == 1 and css.count(END) == 1
|
||||
assert css.index(BEGIN) < css.index(END)
|
||||
between = css[css.index(BEGIN) : css.index(END)]
|
||||
# every verbatim selector lives inside it
|
||||
for selector in PANEL_SELECTORS:
|
||||
assert re.search(r"(?m)^" + re.escape(selector) + r"[^\n]*\{", between), (
|
||||
f"{selector} must be declared inside the labelled phase-182 region"
|
||||
)
|
||||
outside = _strip_comments(css[: css.index(BEGIN)] + css[css.index(END) :])
|
||||
for selector in PANEL_SELECTORS:
|
||||
assert not re.search(
|
||||
r"(?m)^" + re.escape(selector) + r"[^\n]*\{", outside
|
||||
), f"{selector} must not be restyled outside the phase-182 region"
|
||||
|
||||
|
||||
def test_selector_names_match_the_dom_the_renderer_and_task_07_build() -> None:
|
||||
"""The CSS class names are the contract between the renderer's pending
|
||||
template (``markdown.js``) and the panel builder (``verbatim.js``, task
|
||||
07): the pending template's classes must be exactly the ones styled here,
|
||||
so the streaming placeholder is never an unstyled div."""
|
||||
js = (STYLES_CSS.parent / "markdown.js").read_text(encoding="utf-8")
|
||||
for cls in ("verbatim", "verbatim-pending", "verbatim-caption", "verbatim-state"):
|
||||
assert cls in js, f"markdown.js must emit .{cls}"
|
||||
assert _rule_bodies(_BLOCK, "." + cls), f"styles.css must style .{cls}"
|
||||
assert "verbatim-unresolved" in _BLOCK, (
|
||||
"the unresolved class is part of THIS contract (task 07 renders it)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pair", [("--ink", "--surface"), ("--ink-soft", "--surface")])
|
||||
def test_block_pairs_the_documented_text_tokens(pair: tuple[str, str]) -> None:
|
||||
"""The block pairs its text with the canvas the :root comment documents
|
||||
(``--ink`` and ``--ink-soft`` on ``--surface``) rather than inventing a
|
||||
third ink — the AA duty is inherited from the palette, not restated."""
|
||||
code = _strip_comments(_BLOCK)
|
||||
assert re.search(r"color: var\(" + re.escape(pair[0]) + r"\)", code), (
|
||||
f"the block must use {pair[0]}"
|
||||
)
|
||||
assert re.search(r"background: var\(" + re.escape(pair[1]) + r"\)", code), (
|
||||
f"the block must sit on {pair[1]}"
|
||||
)
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Unit: the shared renderer's opt-in ``bor`` locator pass (phase 182, task 06).
|
||||
|
||||
Browser behavior lands in the phase's Playwright suite (task 08); here the
|
||||
established ``test_frontend_*`` convention pins the SOURCE contract of
|
||||
``frontend/assets/markdown.js`` without a browser — the second, optional
|
||||
argument and the single truthy guard, the pass ORDER (verbatim BEFORE
|
||||
fences), the ``\\u0000VERBATIMn\\u0000`` placeholder + restore step, the exact
|
||||
pending template (``verbatim-pending`` / ``data-bor-locator`` /
|
||||
``verbatim-caption`` / ``role="status"`` / the pinned copy
|
||||
``Retrieving the exact text…``), the local ``borLocator`` normalizer and the
|
||||
separator/range grammar it accepts, and the fact that the ordinary-fence
|
||||
``<pre><code>`` output string is UNCHANGED (task 06 item 4: task 07 wraps
|
||||
``<pre>`` nodes at the DOM level instead, so the document viewer, the
|
||||
thinking blocks and every other caller keep byte-identical HTML).
|
||||
|
||||
The node-driven behavioral half (skipped when node is unavailable, the
|
||||
phase-44/138/180 idiom) proves the story shapes: the completion criterion —
|
||||
``renderMarkdown("```bor homelab/a.md 12-40\\nGARBAGE\\n```", {verbatim:
|
||||
true})`` yields the placeholder DOM with
|
||||
``data-bor-locator="homelab/a.md#12-40"`` and NO ``GARBAGE`` anywhere in the
|
||||
returned HTML (locked A7, the phase's core contract) — while the SAME call
|
||||
without the second argument returns the pre-phase output byte-for-byte; a
|
||||
bare `` ```bor `` (no path) survives to the fence pass untouched; an
|
||||
unparseable locator is put back byte-for-byte (no placeholder, no badge, the
|
||||
model's own body — neither improved nor worsened); a fence left OPEN at the
|
||||
very end of the input shows the pending state; two fences restore in answer
|
||||
order; a locator carrying markup stays inert escaped text; and a standalone
|
||||
placeholder is not wrapped in a ``<p>``.
|
||||
|
||||
Parity half: ``borLocator`` is the documented CLIENT MIRROR of Python
|
||||
``app.rag.verbatim.canonical_locator``. The phase's E2E (task 08) pins that
|
||||
parity end to end (both sides must agree or hydration misses); this module
|
||||
pins the GRAMMAR too, by running both implementations over the same table
|
||||
and over a deterministic fuzz — the only way to catch a grammar drift
|
||||
before there is a browser in the loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
#: The single truthy path of the phase-182 second argument.
|
||||
VERBATIM_GUARD = "opts && opts.verbatim === true"
|
||||
|
||||
#: The verbatim fence pass — the pinned regex of task 06 item 2.
|
||||
VERBATIM_FENCE_RE = r"/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm"
|
||||
|
||||
#: The pre-existing fence pass, untouched (task 06 item 4).
|
||||
ORDINARY_FENCE_RULE = (
|
||||
'codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\\n$/, ""))}</code></pre>`);'
|
||||
)
|
||||
|
||||
#: The untouched pre-phase paragraph guard (the phase-138/180 pin).
|
||||
PARAGRAPH_GUARD = r"/^<(h\d|ul|ol|pre|li|blockquote)/"
|
||||
|
||||
#: The placeholder's block-element clause added by task 06.
|
||||
PLACEHOLDER_GUARD = r'/^\u0000VERBATIM\d+\u0000$/'
|
||||
|
||||
#: The pinned pending copy (``…`` is ONE character, not three dots).
|
||||
PENDING_COPY = "Retrieving the exact text\u2026"
|
||||
|
||||
#: The completion criterion's input.
|
||||
CRITERION_INPUT = "```bor homelab/a.md 12-40\nGARBAGE\n```"
|
||||
|
||||
#: The pre-phase (verbatim-OFF) output of :data:`CRITERION_INPUT` — the
|
||||
#: byte-pin of the "inert until opted in" contract. A `` ```bor path `` info
|
||||
#: string never matched the ordinary fence rule (``/```(\w*)\n/`` needs the
|
||||
#: language token ALONE on that line), so OFF it is escaped prose.
|
||||
CRITERION_OFF_OUTPUT = "<p>```bor homelab/a.md 12-40<br>GARBAGE<br>```</p>"
|
||||
|
||||
#: The option task 07 will pass (never passed by any call site yet).
|
||||
ON: dict[str, bool] = {"verbatim": True}
|
||||
|
||||
#: The inputs the node cases render, keyed by case name.
|
||||
CASE_INPUT: dict[str, str] = {
|
||||
"criterion": CRITERION_INPUT,
|
||||
"bare_bor": "```bor\nfoo\n```",
|
||||
"open_bare": "```bor\n",
|
||||
"malformed": "```bor not-a-locator 12-40\nSECRET_BODY\n```",
|
||||
"open_fence": "```bor homelab/a.md 12-40\n",
|
||||
"two_fences": "```bor a/b 1-2\nBODY_ONE\n```\n\n```bor c/d\nBODY_TWO\n```",
|
||||
"en_dash": "```bor a/b 12\u201340\nBODY\n```",
|
||||
"l_prefix": "```bor a/b#L12-L40\nBODY\n```",
|
||||
"xss": "```bor a\"/><script>alert(1)</script>/b.md 1-2\nBODY\n```",
|
||||
"standalone": "prose\n\n```bor homelab/a.md\nBODY\n```\n\nafter",
|
||||
"attached": "prose\n```bor homelab/a.md 1-2\nBODY\n```",
|
||||
"nested": "```\ntext\n```bor a/b 1-2\nBODY\n```\n",
|
||||
"no_bor": "# Title\n\nsome **text** and `code`\n\n| a |\n|---|\n| 1 |\n\n> quote\n",
|
||||
}
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _render(cases: dict[str, dict[str, bool] | None]) -> dict[str, str]:
|
||||
"""Run every case through the REAL renderer under node.
|
||||
|
||||
``None`` means the ONE-argument call (every pre-phase call site); a dict
|
||||
is passed as the second argument as-is.
|
||||
"""
|
||||
parts = []
|
||||
for name, opts in cases.items():
|
||||
arg = "" if opts is None else f", {json.dumps(opts)}"
|
||||
parts.append(f"out.{name} = renderMarkdown({json.dumps(CASE_INPUT[name])}{arg}); ")
|
||||
driver = _text(MARKDOWN_JS) + "\nconst out = {}; " + "".join(parts)
|
||||
driver += "console.log(JSON.stringify(out));"
|
||||
results: dict[str, str] = json.loads(_run_node(driver))
|
||||
return results
|
||||
|
||||
|
||||
def _bor_locator_source() -> str:
|
||||
js = _text(MARKDOWN_JS)
|
||||
start = js.index("function borLocator(raw) {")
|
||||
end = js.index("\nfunction renderMarkdown", start)
|
||||
return js[start:end]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the signature + the guard (task 06 item 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_markdown_takes_the_optional_opts_argument() -> None:
|
||||
"""``renderMarkdown(md)`` became ``renderMarkdown(md, opts)`` — a SECOND,
|
||||
OPTIONAL argument, so every existing call site (the document viewer, the
|
||||
thinking blocks, the user bubbles) keeps working untouched — and the
|
||||
ONLY truthy path is ``opts.verbatim === true`` (a strict ``===``, so
|
||||
``{verbatim: 1}`` / ``{verbatim: "yes"}`` stay OFF)."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert "function renderMarkdown(md, opts) {" in js, (
|
||||
"renderMarkdown must gain the second, optional `opts` argument"
|
||||
)
|
||||
assert "function renderMarkdown(md) {" not in js, "the one-argument signature is gone"
|
||||
body = js[js.index("function renderMarkdown(md, opts) {") :]
|
||||
assert body.count(VERBATIM_GUARD) == 1, (
|
||||
f"the pass must be guarded by exactly one `{VERBATIM_GUARD}` test"
|
||||
)
|
||||
assert "if (opts && " in js, "a missing opts object must be handled (no TypeError)"
|
||||
|
||||
|
||||
def test_header_comment_records_the_phase_182_pass() -> None:
|
||||
"""The header comment carries the three things the next reader needs: the
|
||||
pass exists and is opt-in, WHY the model's body is discarded (locked A7),
|
||||
and the byte-identical-when-off contract."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
header = js[: js.find("function escapeHtml")]
|
||||
flat = " ".join(
|
||||
" ".join(line.strip().lstrip("*").strip() for line in header.splitlines()).split()
|
||||
)
|
||||
for needle, label in [
|
||||
("Phase 182", "the phase reference"),
|
||||
("opts && opts.verbatim === true", "the single truthy path"),
|
||||
("BODY IS DISCARDED", "why the model's bytes are dropped"),
|
||||
("locked A7", "the locked decision behind it"),
|
||||
("canonical_locator", "the Python function this mirrors"),
|
||||
("BYTE-IDENTICAL WHEN OFF", "the inert-when-off contract"),
|
||||
("verbatim \u2192 fences \u2192 tables", "the new pass order"),
|
||||
("STRICTER", "the deliberate asymmetry with the server's fences_in"),
|
||||
("model's own body", "the malformed-locator degradation"),
|
||||
]:
|
||||
assert needle in flat, f"the header comment must record {label}: {needle!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the pass + the placeholder (task 06 item 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verbatim_pass_runs_before_the_fence_pass() -> None:
|
||||
"""Order is verbatim → fences → tables → escape+inline → blockquotes →
|
||||
paragraphs → restore. The verbatim regex MUST sit ahead of the ordinary
|
||||
fence replace, otherwise a locator fence is eaten as a code block first
|
||||
and the placeholder never exists."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert VERBATIM_FENCE_RE in js, f"the pinned fence regex is missing: {VERBATIM_FENCE_RE}"
|
||||
fence_pass = js.find(ORDINARY_FENCE_RULE)
|
||||
assert fence_pass != -1, "the ordinary fence pass must still be there"
|
||||
assert js.find(VERBATIM_FENCE_RE) < fence_pass, (
|
||||
"the verbatim pass must run BEFORE the fence pass"
|
||||
)
|
||||
# …and the rest of the pipeline keeps its order behind it.
|
||||
for marker in ("const tables = []", "text = inline(escapeHtml(text));", r"split(/\n{2,}/)"):
|
||||
assert fence_pass < js.find(marker), f"the pass order broke at {marker!r}"
|
||||
|
||||
|
||||
def test_placeholder_is_created_and_restored() -> None:
|
||||
"""A parsed locator becomes ``\\u0000VERBATIMn\\u0000`` and the FINAL restore
|
||||
step (alongside TABLE/CODE) turns it back into the panel — the
|
||||
placeholder survives escaping (NUL and digits are not escaped) and never
|
||||
reaches the DOM itself."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert "`\\u0000VERBATIM${verbatim.length - 1}\\u0000`" in js, (
|
||||
"the placeholder must be the indexed VERBATIM marker"
|
||||
)
|
||||
restore = r".replace(/\u0000VERBATIM(\d+)\u0000/g"
|
||||
assert restore in js, "the restore step must replace the VERBATIM placeholder"
|
||||
table = js.find(r".replace(/\u0000TABLE(\d+)\u0000/g")
|
||||
code = js.find(r".replace(/\u0000CODE(\d+)\u0000/g")
|
||||
assert table < code < js.find(restore), "the restore order is TABLE → CODE → VERBATIM"
|
||||
|
||||
|
||||
def test_pending_template_is_exact() -> None:
|
||||
"""The restore emits EXACTLY the pinned pending template: the
|
||||
``verbatim verbatim-pending`` panel, the canonical locator in
|
||||
``data-bor-locator`` (task 07's hydration key), the clipped
|
||||
``verbatim-caption``, and the ``role="status"`` state line carrying the
|
||||
pinned copy — every interpolated value through ``escapeHtml``."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
for marker in (
|
||||
'<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">',
|
||||
'<span class="verbatim-caption">${escapeHtml(identity)}</span>',
|
||||
f'<span class="verbatim-state" role="status">{PENDING_COPY}</span>',
|
||||
):
|
||||
assert marker in js, f"the pending template must contain {marker!r}"
|
||||
assert 'data-bor-locator="${canonical}"' not in js, (
|
||||
"the locator is model-written text landing in an ATTRIBUTE — it must "
|
||||
"go through escapeHtml (the escape-first boundary)"
|
||||
)
|
||||
|
||||
|
||||
def test_malformed_locator_is_put_back_byte_for_byte() -> None:
|
||||
"""``borLocator`` returning ``""`` returns the ORIGINAL match, so a
|
||||
malformed locator degrades to whatever the fence pass does with this text
|
||||
today (no placeholder, no badge, the model's own body) — this phase
|
||||
neither improves nor worsens it."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert 'if (canonical === "") return match;' in js, (
|
||||
"an unparseable locator must be returned to the text unchanged"
|
||||
)
|
||||
|
||||
|
||||
def test_paragraph_guard_blocks_placeholder_and_untouched_guard_stays() -> None:
|
||||
"""The step-3 guard keeps the pre-phase test verbatim (the phase-138/180
|
||||
pin) and adds the placeholder clause, so a standalone panel restores as a
|
||||
``<div>`` instead of landing inside a ``<p>``."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert PARAGRAPH_GUARD in js, "the pre-phase paragraph guard must stay"
|
||||
assert js.count(PARAGRAPH_GUARD) == 1, "there is exactly one paragraph guard"
|
||||
assert PLACEHOLDER_GUARD in js, "the placeholder must be a block element too"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# borLocator — the client mirror (task 06 item 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bor_locator_exists_and_handles_every_grammar_token() -> None:
|
||||
"""``borLocator`` exists, is documented as the CLIENT MIRROR of
|
||||
``app.rag.verbatim.canonical_locator``, and its body carries every
|
||||
separator (`` ` ``, ``:``, ``#``, the ``L`` prefix) and range shape
|
||||
(``12-40``, the en dash, ``L12-L40``, a bare ``12``) of the D7 grammar —
|
||||
plus the refusals (non-numeric, ``< 1``, ``start > end``, trailing
|
||||
junk)."""
|
||||
body = _bor_locator_source()
|
||||
for token, label in [
|
||||
(r"[ \t:#]+", "the ` `/`:`/`#` separator class"),
|
||||
(r"L?\d+", "the optional `L` line prefix"),
|
||||
(r"[-\u2013]", "the hyphen AND the en dash range separator"),
|
||||
("(?:", "the OPTIONAL range group (a bare `12` and no range at all)"),
|
||||
("$/", "the end anchor (trailing junk is not a range)"),
|
||||
('includes("/")', "the first-`/` identity requirement"),
|
||||
("start < 1", "the `< 1` refusal"),
|
||||
("start > end", "the `start > end` refusal"),
|
||||
('replace(/^[Ll]+/, "")', "the `L` prefix strip on each bound"),
|
||||
]:
|
||||
assert token in body, f"borLocator must handle {label}: {token!r}"
|
||||
|
||||
|
||||
def test_bor_locator_is_documented_as_the_python_mirror() -> None:
|
||||
"""The docstring above ``borLocator`` names the Python function it
|
||||
mirrors and states that parity is load-bearing (a divergence means
|
||||
hydration misses), so nobody 'fixes' one side alone."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
comment = js[: js.index("function borLocator(raw) {")]
|
||||
flat = " ".join(
|
||||
" ".join(line.strip().lstrip("/").strip() for line in comment.splitlines()).split()
|
||||
)
|
||||
for needle, label in [
|
||||
("canonical_locator", "the Python function"),
|
||||
("CLIENT MIRROR", "that this is the mirror, not the source"),
|
||||
("PARITY IS LOAD-BEARING", "why both sides must agree"),
|
||||
("#12-40", "the canonical range form"),
|
||||
]:
|
||||
assert needle in flat, f"the docstring must record {label}: {needle!r}"
|
||||
|
||||
|
||||
def test_bor_locator_returns_canonical_digits_or_empty() -> None:
|
||||
"""Source shape: the canonical output is ``identity#start-end`` (digits
|
||||
only) and every refusal path returns ``""`` — never ``null``/``undefined``
|
||||
(the renderer compares against ``""``)."""
|
||||
body = _bor_locator_source()
|
||||
assert "return `${identity}#${start}-${end}`;" in body
|
||||
assert body.count('return "";') >= 6, "every refusal path must return the empty string"
|
||||
assert "return null" not in body and "return undefined" not in body, (
|
||||
'borLocator\'s not-a-locator value is "", so the renderer\'s '
|
||||
'`if (canonical === "")` test cannot miss'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ordinary fences are NOT wrapped in markdown (task 06 item 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ordinary_fence_output_string_is_unchanged() -> None:
|
||||
"""Task 06 item 4: do NOT wrap ordinary fences in markdown — task 07
|
||||
wraps ``<pre>`` nodes at the DOM level, so the document viewer, the
|
||||
thinking blocks and every other caller keep byte-identical HTML. The
|
||||
fence rule and its ``<pre><code>`` output string are pinned verbatim."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert r"/```(\w*)\n([\s\S]*?)```/g" in js, "the ordinary fence regex is unchanged"
|
||||
assert ORDINARY_FENCE_RULE in js, "the ordinary fence output string is unchanged"
|
||||
assert 'class="code-block"' not in js, (
|
||||
"markdown.js must NOT emit the task-07 wrapper — that is a DOM-level wrap"
|
||||
)
|
||||
|
||||
|
||||
#: The answer renderers task 07 opted IN (the whole client switch — the
|
||||
#: per-delta write, the ``done``-time re-render, the brain restore).
|
||||
OPTED_IN_CALLS: tuple[str, ...] = (
|
||||
"renderMarkdown(acc, { verbatim: true })",
|
||||
"renderMarkdown(finalText, { verbatim: true })",
|
||||
"renderMarkdown(m.text, { verbatim: true })",
|
||||
)
|
||||
|
||||
|
||||
def test_only_the_answer_renderers_opted_in() -> None:
|
||||
"""Task 07 opted in exactly the THREE answer renderers (the per-delta
|
||||
write, the ``done``-time re-render, the brain restore) — and the shared
|
||||
page's one brain renderer. NOTHING else: the user-bubble renders, the
|
||||
live thinking runs, the legacy thinking restore and the whole document
|
||||
viewer stay one-argument calls, because a question and a scratchpad are
|
||||
not locators (task 06's byte-identity contract for every other surface).
|
||||
The count is the pin: a fourth ``verbatim: true`` in either file is a
|
||||
surface the phase never claimed."""
|
||||
app = _text(FRONTEND / "assets" / "app.js")
|
||||
shared = _text(FRONTEND / "assets" / "shared.js")
|
||||
for call in OPTED_IN_CALLS:
|
||||
assert call in app, f"app.js must render the answer through {call}"
|
||||
assert app.count("verbatim: true") == 3, (
|
||||
"exactly the three answer renderers may opt in in app.js"
|
||||
)
|
||||
assert (
|
||||
'addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }))' in shared
|
||||
)
|
||||
assert shared.count("verbatim: true") == 1, "the shared page opts in its one renderer"
|
||||
# The neighbours stay one-argument, in both files.
|
||||
assert 'addMessage("user", renderMarkdown(m.text), true)' in app
|
||||
assert 'addSharedMessage("user", renderMarkdown(m.text))' in shared
|
||||
for name in ("app.js", "shared.js"):
|
||||
js = _text(FRONTEND / "assets" / name)
|
||||
for site in re.findall(r"renderMarkdown\([^\n]*\)", js):
|
||||
if "thinking" in site or "entry.text" in site:
|
||||
assert "verbatim" not in site, f"{name}: {site} must stay one-argument"
|
||||
# A document's own ```bor fence is the document's text — never a locator.
|
||||
assert "verbatim: true" not in _text(FRONTEND / "assets" / "document.js")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# behavior (node): the story shapes + the completion criterion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_locator_fence_yields_the_placeholder_and_drops_the_model_bytes() -> None:
|
||||
"""THE completion criterion: the criterion input WITH ``{verbatim: true}``
|
||||
yields the placeholder DOM with
|
||||
``data-bor-locator="homelab/a.md#12-40"`` and NO ``GARBAGE`` anywhere in
|
||||
the returned HTML (locked A7). Two fences restore in answer order, the en
|
||||
dash and the ``L`` prefix canonicalize, and a standalone panel is not
|
||||
wrapped in a ``<p>``."""
|
||||
out = _render(
|
||||
{
|
||||
"criterion": ON,
|
||||
"two_fences": ON,
|
||||
"en_dash": ON,
|
||||
"l_prefix": ON,
|
||||
"standalone": ON,
|
||||
}
|
||||
)
|
||||
html = out["criterion"]
|
||||
assert 'class="verbatim verbatim-pending"' in html
|
||||
assert 'data-bor-locator="homelab/a.md#12-40"' in html
|
||||
assert ">homelab/a.md</span>" in html, "the caption carries the source/path"
|
||||
assert f">{PENDING_COPY}</span>" in html
|
||||
assert 'role="status"' in html
|
||||
assert "GARBAGE" not in html, "the model's body must appear NOWHERE (locked A7)"
|
||||
assert "\u0000" not in html, "no placeholder byte survives"
|
||||
assert "<p>" not in html, "a standalone panel is a block, not a <p>"
|
||||
|
||||
two = out["two_fences"]
|
||||
assert two.index("a/b#1-2") < two.index('data-bor-locator="c/d"'), (
|
||||
"two fences keep answer order"
|
||||
)
|
||||
assert two.count("verbatim-pending") == 2
|
||||
assert "BODY_ONE" not in two and "BODY_TWO" not in two
|
||||
assert out["en_dash"].count('data-bor-locator="a/b#12-40"') == 1
|
||||
assert "BODY" not in out["en_dash"]
|
||||
assert out["l_prefix"].count('data-bor-locator="a/b#12-40"') == 1
|
||||
assert "BODY" not in out["l_prefix"]
|
||||
assert out["standalone"] == (
|
||||
'<p>prose</p><div class="verbatim verbatim-pending" '
|
||||
'data-bor-locator="homelab/a.md"><span class="verbatim-caption">homelab/a.md</span>'
|
||||
f'<span class="verbatim-state" role="status">{PENDING_COPY}</span></div><p>after</p>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_off_paths_are_byte_identical_to_the_pre_phase_renderer() -> None:
|
||||
"""Every call WITHOUT ``{verbatim: true}`` — no second argument, the
|
||||
document viewer's one-argument call, and an explicit
|
||||
``{verbatim: false}`` — returns the PRE-PHASE output byte-for-byte: the
|
||||
criterion input is escaped prose (a `` ```bor path `` info string never
|
||||
matched the ``/```(\\w*)\\n/`` fence rule), and a locator-free document
|
||||
renders identically with and without the option."""
|
||||
out = _render(
|
||||
{
|
||||
"criterion": None,
|
||||
"malformed": None,
|
||||
"two_fences": None,
|
||||
"standalone": None,
|
||||
"nested": None,
|
||||
"no_bor": None,
|
||||
}
|
||||
)
|
||||
assert out["criterion"] == CRITERION_OFF_OUTPUT, "the OFF path must not change a byte"
|
||||
for name, html in out.items():
|
||||
assert "verbatim" not in html, f"{name}: no placeholder markup with the option OFF"
|
||||
# the option object exists but is false → the same bytes as no argument
|
||||
on_false = _render({"criterion": {"verbatim": False}})
|
||||
assert on_false["criterion"] == CRITERION_OFF_OUTPUT
|
||||
truthy_but_strict = _render({"criterion": {}})
|
||||
assert truthy_but_strict["criterion"] == CRITERION_OFF_OUTPUT, (
|
||||
"the guard is `=== true`: an options object without the flag stays OFF"
|
||||
)
|
||||
# a locator-free document is untouched by the whole feature
|
||||
assert _render({"no_bor": ON})["no_bor"] == out["no_bor"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_bare_and_malformed_fences_fall_through_to_the_ordinary_pass() -> None:
|
||||
"""A bare `` ```bor `` (no path) is an ordinary code block and survives to
|
||||
the fence pass UNTOUCHED even with the option on; a bare `` ```bor ``
|
||||
with no body at all still paints its raw marker as prose; and an
|
||||
unparseable locator degrades to exactly what the fence pass does with
|
||||
this text today — the model's own body, no placeholder, no badge."""
|
||||
out = _render({"bare_bor": ON, "open_bare": ON, "malformed": ON})
|
||||
assert out["bare_bor"] == "<p><pre><code>foo</code></pre></p>", (
|
||||
"a bare ```bor fence must render EXACTLY as it does pre-phase"
|
||||
)
|
||||
assert out["open_bare"] == "<p>```bor</p>"
|
||||
assert out["malformed"] == "<p>```bor not-a-locator 12-40<br>SECRET_BODY<br>```</p>", (
|
||||
"a malformed locator is put back byte-for-byte: no placeholder, "
|
||||
"no badge, the model's own body"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_open_fence_at_the_end_shows_the_pending_state() -> None:
|
||||
"""The second alternative: a fence left OPEN at the very end of the input
|
||||
(the info line typed, the body not yet streamed) shows the pending
|
||||
placeholder instead of raw backticks — the deliberate asymmetry with the
|
||||
server's stricter ``fences_in`` (the server only ever sees a finished
|
||||
answer, the client sees a partial one)."""
|
||||
out = _render({"open_fence": ON})
|
||||
html = out["open_fence"]
|
||||
assert 'data-bor-locator="homelab/a.md#12-40"' in html
|
||||
assert "```" not in html, "no raw backticks while a locator fence is open"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_locator_markup_stays_inert_escaped_text() -> None:
|
||||
"""Escape-first holds on the new surface: a locator carrying ``"><script>``
|
||||
is HTML-escaped in BOTH the attribute and the caption, so it can never
|
||||
become a live element (the renderer's whole reason for existing)."""
|
||||
html = _render({"xss": ON})["xss"]
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
assert '"/><script>' in html
|
||||
assert "#1-2" in html, "the markup still canonicalized to a locator"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_a_bor_line_inside_an_ordinary_fence_agrees_with_the_server() -> None:
|
||||
"""Documented limitation shared with ``fences_in`` (both sides scan for
|
||||
the opening line, neither supports a nested `` ``` ``): a ``bor`` locator
|
||||
line inside an ordinary fence is a LOCATOR on both sides, so hydration
|
||||
still matches — it is never silently half-code, half-panel on one side
|
||||
only."""
|
||||
from app.rag.verbatim import fences_in
|
||||
|
||||
html = _render({"nested": ON})["nested"]
|
||||
assert 'data-bor-locator="a/b#1-2"' in html
|
||||
assert fences_in(CASE_INPUT["nested"]) == ["a/b 1-2"], (
|
||||
"the server must see the SAME locator in the same text"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_fence_attached_to_prose_still_loses_its_body() -> None:
|
||||
"""A locator fence with NO blank line above it still loses the model's
|
||||
body (the browser closes the surrounding ``<p>`` when it parses the
|
||||
``<div>``, the same fix-up an ordinary fenced ``<pre>`` already relies
|
||||
on) — the body never paints either way."""
|
||||
html = _render({"attached": ON})["attached"]
|
||||
assert 'data-bor-locator="homelab/a.md#1-2"' in html
|
||||
assert "BODY" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parity with the server's canonical_locator (task 06 item 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The D7 grammar table: input → the canonical string BOTH sides must
|
||||
#: produce, or ``""`` for "not a locator at all".
|
||||
PARITY_TABLE: dict[str, str] = {
|
||||
"homelab/a.md": "homelab/a.md",
|
||||
"homelab/a.md 12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md:12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md#12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md#L12-L40": "homelab/a.md#12-40",
|
||||
"homelab/a.md 12\u201340": "homelab/a.md#12-40",
|
||||
"homelab/a.md 12": "homelab/a.md#12-12",
|
||||
"homelab/a.md 12-40": "homelab/a.md#12-40",
|
||||
"homelab/a.md 12 - 40": "homelab/a.md#12-40",
|
||||
" homelab/a.md ": "homelab/a.md",
|
||||
"homelab/deep/dir/a.md#L3": "homelab/deep/dir/a.md#3-3",
|
||||
"homelab/a.md#l3-l7": "homelab/a.md#3-7",
|
||||
"homelab/a.md L7": "homelab/a.md#7-7",
|
||||
# the en dash is a RANGE separator, never the identity/range separator,
|
||||
# so this row is ONE whole-document identity whose path contains one:
|
||||
"homelab/a.md\u201312-40": "homelab/a.md\u201312-40",
|
||||
# refusals — not locators at all:
|
||||
"a.md": "",
|
||||
"": "",
|
||||
" ": "",
|
||||
"homelab/a.md 0-5": "",
|
||||
"homelab/a.md 40-12": "",
|
||||
"homelab/a.md x-y": "",
|
||||
"homelab/a.md 12-40 extra": "",
|
||||
"bor homelab/a.md": "",
|
||||
"homelab/a.md#": "",
|
||||
"homelab/a.md:": "",
|
||||
"homelab/a.md-": "",
|
||||
"homelab/my note.md 12": "",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_bor_locator_matches_the_python_canonical_locator() -> None:
|
||||
"""The mirror is exact: for every row of the grammar table the JS
|
||||
``borLocator`` and the Python ``canonical_locator`` agree — and the table
|
||||
itself is checked against the D7 spec, so a SIMULTANEOUS drift on both
|
||||
sides is caught too. A divergence here means hydration misses: the panel
|
||||
would sit unresolved forever."""
|
||||
from app.rag.verbatim import canonical_locator
|
||||
|
||||
script = (
|
||||
_bor_locator_source()
|
||||
+ "\nconst t = "
|
||||
+ json.dumps(PARITY_TABLE)
|
||||
+ ";\nconst out = {};\nfor (const k of Object.keys(t)) out[k] = borLocator(k);\n"
|
||||
"console.log(JSON.stringify(out));\n"
|
||||
)
|
||||
got: dict[str, str] = json.loads(_run_node(script))
|
||||
for raw, expected in PARITY_TABLE.items():
|
||||
assert got[raw] == expected, f"borLocator({raw!r}) != {expected!r} (got {got[raw]!r})"
|
||||
python = canonical_locator(raw) or ""
|
||||
assert python == expected, (
|
||||
f"canonical_locator({raw!r}) = {python!r} disagrees with the D7 "
|
||||
f"table value {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_fuzz_locator_parity() -> None:
|
||||
"""A deterministic fuzz over the grammar's alphabet: the two
|
||||
implementations must agree on every string, including the shapes nobody
|
||||
hand-wrote (dangling separators, multi-bound ranges, lone ``L``s)."""
|
||||
from app.rag.verbatim import canonical_locator
|
||||
|
||||
rng = random.Random(182)
|
||||
alphabet = [
|
||||
"a", "b", "/", ".", ":", "#", " ", "L", "l", "-", "\u2013", "1", "2", "9", "x", "\t",
|
||||
]
|
||||
samples = [
|
||||
"".join(rng.choice(alphabet) for _ in range(rng.randint(0, 9))) for _ in range(400)
|
||||
]
|
||||
script = (
|
||||
_bor_locator_source()
|
||||
+ "\nconst t = "
|
||||
+ json.dumps(samples)
|
||||
+ ";\nconst out = [];\nfor (const k of t) out.push(borLocator(k));\n"
|
||||
"console.log(JSON.stringify(out));\n"
|
||||
)
|
||||
got: list[str] = json.loads(_run_node(script))
|
||||
for raw, js_value in zip(samples, got, strict=True):
|
||||
python = canonical_locator(raw) or ""
|
||||
assert js_value == python, f"parity break on {raw!r}: js={js_value!r} py={python!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# no regression on the neighboring passes (the phase's byte-identity mandate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adjacent_renderer_fixtures_stay_pinned_where_they_live() -> None:
|
||||
"""The phase-44 table, phase-138 blockquote and phase-180 image fixtures
|
||||
are the byte-identity evidence for the passes this task sits next to —
|
||||
they live in their own suites, so this pins that those suites still pin
|
||||
them (a deleted fixture here would silently drop the guarantee)."""
|
||||
for name, needle in [
|
||||
("test_markdown_tables.py", "md-table-wrap"),
|
||||
("test_markdown_blockquotes.py", '<blockquote class="md-quote">'),
|
||||
("test_markdown_inline_links_images.py", 'class="md-img"'),
|
||||
]:
|
||||
text = _text(Path(__file__).resolve().parent / name)
|
||||
assert needle in text, f"{name} must still pin {needle!r}"
|
||||
@@ -123,8 +123,12 @@ def test_agent_tools_off_is_the_three_base_tools() -> None:
|
||||
"""LOCKED A2: toggle off (the default) → exactly the three base
|
||||
tools, element-for-element equal to ``AGENT_TOOLS`` — and a FRESH
|
||||
list (not the constant), so a caller mutating the offer can never
|
||||
mutate the constant."""
|
||||
off = agent_tools(_settings())
|
||||
mutate the constant. Phase 182 (task 03) pin update, deliberate:
|
||||
the flag pin passes ``answer_text=False`` explicitly — with
|
||||
``answer_text`` ON (the shipped default) the offer additionally
|
||||
carries ``show_text`` (pinned in ``tests/unit/test_agent.py``); the
|
||||
``answer_images`` gate itself is unchanged."""
|
||||
off = agent_tools(_settings(answer_text=False))
|
||||
assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"]
|
||||
assert off == AGENT_TOOLS # element-for-element
|
||||
assert off is not AGENT_TOOLS # a fresh list, never the constant itself
|
||||
@@ -136,8 +140,12 @@ def test_agent_tools_on_appends_show_image() -> None:
|
||||
"""Toggle on → the three base tools (byte-identical) plus the 4th
|
||||
tool, ``show_image`` — the offer list the grounded turn passes to
|
||||
the model request (LOCKED A2: ``BOR_ANSWER_IMAGES`` gates ONLY the
|
||||
offer; the base three are unchanged)."""
|
||||
on = agent_tools(_settings(answer_images=True))
|
||||
offer; the base three are unchanged). Phase 182 (task 03) pin
|
||||
update, deliberate: ``answer_text=False`` isolates the
|
||||
``answer_images`` gate — with ``answer_text`` ON (the shipped
|
||||
default) ``show_text`` rides LAST (pinned in
|
||||
``tests/unit/test_agent.py``)."""
|
||||
on = agent_tools(_settings(answer_images=True, answer_text=False))
|
||||
assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"]
|
||||
assert on[:3] == AGENT_TOOLS # the base three byte-identical
|
||||
assert on[3] == SHOW_IMAGE_TOOL
|
||||
@@ -166,7 +174,11 @@ def test_run_agent_gates_the_show_image_offer() -> None:
|
||||
"""The wiring pin (completion criteria): a grounded turn's offer
|
||||
(the ``tools`` on the model request, ``max_rounds`` > 0) is the
|
||||
three base tools when the toggle is off — element-for-element the
|
||||
pre-phase-124 list — and gains ``show_image`` only when it is on."""
|
||||
pre-phase-124 list — and gains ``show_image`` only when it is on.
|
||||
Phase 182 (task 03) pin update, deliberate: both apps pass
|
||||
``answer_text=False`` to isolate the ``answer_images`` gate (with
|
||||
the shipped default ON, ``show_text`` additionally rides last —
|
||||
pinned in ``tests/unit/test_agent.py``)."""
|
||||
|
||||
class _ScriptedLLM:
|
||||
"""One canned content-only round; records the ``tools`` every
|
||||
@@ -207,12 +219,12 @@ def test_run_agent_gates_the_show_image_offer() -> None:
|
||||
pass
|
||||
|
||||
llm_off = _ScriptedLLM()
|
||||
asyncio.run(_consume(llm_off, _settings()))
|
||||
asyncio.run(_consume(llm_off, _settings(answer_text=False)))
|
||||
assert len(llm_off.requests) == 1
|
||||
assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124
|
||||
|
||||
llm_on = _ScriptedLLM()
|
||||
asyncio.run(_consume(llm_on, _settings(answer_images=True)))
|
||||
asyncio.run(_consume(llm_on, _settings(answer_images=True, answer_text=False)))
|
||||
sent = llm_on.requests[0][1]
|
||||
assert sent is not None # the grounded turn (max_rounds > 0) offers tools
|
||||
assert [t["function"]["name"] for t in sent] == [
|
||||
@@ -427,14 +439,24 @@ def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
|
||||
IDENTICAL to the pre-task build when ``answer_images`` is off (the
|
||||
default — the pre-phase pins pass untouched) and gains the clause
|
||||
(inside ``<tools>``) exactly when it is on."""
|
||||
off = _plan3(0.9, _settings())
|
||||
off_settings = _settings()
|
||||
off = _plan3(0.9, off_settings)
|
||||
assert off.deflected is False
|
||||
assert off.system_prompt == build_high_prompt([_P3_DOC])
|
||||
# Phase 182 (task 04): the reconstruction mirrors the OTHER display
|
||||
# clause's flag too (BOR_ANSWER_TEXT, default ON) so this pin stays
|
||||
# about answer_images alone — the show_text clause is pinned in
|
||||
# tests/unit/test_prompts.py.
|
||||
assert off.system_prompt == build_high_prompt(
|
||||
[_P3_DOC], show_text=off_settings.answer_text
|
||||
)
|
||||
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
|
||||
|
||||
on = _plan3(0.9, _settings(answer_images=True))
|
||||
on_settings = _settings(answer_images=True)
|
||||
on = _plan3(0.9, on_settings)
|
||||
assert on.deflected is False
|
||||
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
|
||||
assert on.system_prompt == build_high_prompt(
|
||||
[_P3_DOC], show_image=True, show_text=on_settings.answer_text
|
||||
)
|
||||
assert SHOW_IMAGE_CLAUSE in on.system_prompt
|
||||
assert on.system_prompt.index("<tools>") < on.system_prompt.index(SHOW_IMAGE_CLAUSE)
|
||||
assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("</tools>")
|
||||
|
||||
@@ -599,13 +599,18 @@ def test_meta_rows_append_to_msg_body_never_into_the_bubble() -> None:
|
||||
|
||||
def test_restore_renders_brain_text_through_the_pinned_call_shape() -> None:
|
||||
"""CORNER: the restore. The phase-14 restore renders every brain
|
||||
record through `addMessage("brain", renderMarkdown(m.text), true)`
|
||||
— with `m.text === ""` the node-pinned `renderMarkdown("") === ""
|
||||
record through `addMessage("brain", renderMarkdown(m.text, { verbatim:
|
||||
true }), true)` (the option is phase 182's verbatim opt-in — it changes
|
||||
which FENCES render, never whether the text does) — with `m.text === ""`
|
||||
the node-pinned `renderMarkdown("") === ""`
|
||||
` (pinned above) makes the bubble CHILD-LESS ⇒ `:empty` ⇒ hidden:
|
||||
an empty persisted answer paints no box, through the SAME rule
|
||||
(locked A3) — no restore-path branch was added."""
|
||||
js = _js()
|
||||
assert js.count('addMessage("brain", renderMarkdown(m.text), true)') == 1
|
||||
assert (
|
||||
js.count('addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)')
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_shared_page_builds_the_identical_msg_structure_no_change_needed() -> None:
|
||||
|
||||
@@ -18,10 +18,13 @@ from app.rag.prompts import (
|
||||
ORIGIN_RULE,
|
||||
PERSONA,
|
||||
SEARCH_DISCIPLINE_RULE,
|
||||
SHOW_IMAGE_CLAUSE,
|
||||
SHOW_TEXT_CLAUSE,
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_base,
|
||||
build_deflect_prompt,
|
||||
build_high_prompt,
|
||||
)
|
||||
|
||||
|
||||
@@ -323,3 +326,50 @@ def test_suggest_intro_byte_locked() -> None:
|
||||
) in SUGGEST_INTRO
|
||||
# The retired phase-118 final sentence is gone from the constant.
|
||||
assert "Cite the document(s) you used, by path." not in SUGGEST_INTRO
|
||||
|
||||
|
||||
# ---------- SHOW_TEXT_CLAUSE (phase 182, task 04: verbatim display) ----------
|
||||
|
||||
#: Anchors for ``SHOW_TEXT_CLAUSE`` — the ``show_text`` / ```bor``` locator
|
||||
#: teaching appended INSIDE ``<tools>`` when ``BOR_ANSWER_TEXT`` is on
|
||||
#: (phase 182, LOCKED A1–A3/A6). Pinned the way ``TOOLS_SECTION`` is
|
||||
#: (sha256 + exact prefix + total length) because this clause IS the fix
|
||||
#: for the retype bug the phase exists for: a silent wording drift here
|
||||
#: re-opens it (the tool alone never teaches that the user copies from
|
||||
#: what is DISPLAYED, nor the fence syntax).
|
||||
SHOW_TEXT_CLAUSE_SHA256 = "e03a8153ca6cde61c9437f3b66b4fa07cd2a3f9f2e24e3bb8c37ca1d7c7e9e3b"
|
||||
SHOW_TEXT_CLAUSE_LEN = 1179
|
||||
SHOW_TEXT_CLAUSE_PREFIX = (
|
||||
"`show_text` displays part of a document to the user EXACTLY as stored"
|
||||
)
|
||||
|
||||
|
||||
def test_show_text_clause_byte_locked() -> None:
|
||||
"""The clause is LOCKED copy: sha256 + exact prefix + total length,
|
||||
plus the two sentences the phase's contract depends on verbatim (the
|
||||
copy-from-the-display rule and the server-owns-the-numbers rule)."""
|
||||
assert len(SHOW_TEXT_CLAUSE) == SHOW_TEXT_CLAUSE_LEN
|
||||
assert _sha256(SHOW_TEXT_CLAUSE) == SHOW_TEXT_CLAUSE_SHA256
|
||||
assert SHOW_TEXT_CLAUSE.startswith(SHOW_TEXT_CLAUSE_PREFIX)
|
||||
assert SHOW_TEXT_CLAUSE.endswith("For an image use `show_image` instead.")
|
||||
assert "the user copies from what is displayed, not from your prose" in SHOW_TEXT_CLAUSE
|
||||
assert "the numbers you display are the server's, never yours" in SHOW_TEXT_CLAUSE
|
||||
|
||||
|
||||
def test_high_prompt_display_clause_composition_is_byte_locked() -> None:
|
||||
"""One mechanism, four combinations (phase 182, task 04): neither
|
||||
flag reproduces the untouched :data:`TOOLS_SECTION` tail,
|
||||
``show_image`` alone reproduces the phase-124 bytes exactly,
|
||||
``show_text`` alone appends only the text clause, and both append
|
||||
the image clause, a blank line, the text clause, then the closing
|
||||
tag — ``TOOLS_SECTION`` itself is never edited (its own sha pin
|
||||
above holds)."""
|
||||
off = build_high_prompt([])
|
||||
assert off.endswith(TOOLS_SECTION)
|
||||
head = off[: -len("</tools>")]
|
||||
assert build_high_prompt([], show_image=True) == head + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
assert build_high_prompt([], show_text=True) == head + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert (
|
||||
build_high_prompt([], show_image=True, show_text=True)
|
||||
== head + SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
)
|
||||
|
||||
@@ -30,6 +30,15 @@ discipline rules stay byte-identical): the teaching refusals in
|
||||
:mod:`app.rag.agent` re-state the same contract; the ``<tools>``
|
||||
marker keying (HIGH only) is unchanged.
|
||||
|
||||
And the phase-182 ``show_text`` clause (task 04, LOCKED A1–A3/A6): the
|
||||
verbatim-display teaching appended INSIDE ``<tools>`` when
|
||||
``BOR_ANSWER_TEXT`` is on — the user copies from what is DISPLAYED, never
|
||||
from the model's retype, and the inline ```bor <source/path> [range]```
|
||||
fence places a server-resolved block. The clause composes with the
|
||||
phase-124 ``show_image`` clause through one mechanism, and every build
|
||||
the flags do not select stays byte-identical (the constant's own sha pin
|
||||
lives in :mod:`tests.unit.test_prompt_lock`).
|
||||
|
||||
And the phase-119 cite discipline (task 04, LOCKED A5): the intro's
|
||||
final sentence ("cite the document(s) you used, by path", phase 118)
|
||||
is REPLACED — the HIGH prompt carries the discipline sentence exactly
|
||||
@@ -40,6 +49,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -52,6 +62,7 @@ from app.rag.prompts import (
|
||||
PERSONA,
|
||||
SEARCH_DISCIPLINE_RULE,
|
||||
SHOW_IMAGE_CLAUSE,
|
||||
SHOW_TEXT_CLAUSE,
|
||||
SUGGEST_INTRO,
|
||||
TOOLS_SECTION,
|
||||
_base,
|
||||
@@ -1281,6 +1292,170 @@ def test_show_image_clause_is_last_for_every_high_shape() -> None:
|
||||
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
|
||||
|
||||
|
||||
# ---------- phase 182 (task 04, LOCKED A1–A3/A6): the show_text clause ----------
|
||||
#
|
||||
# The clause is the fix for the retype bug: nothing in the pre-phase prompt
|
||||
# ever said the user copies from what is DISPLAYED, so the model retyped
|
||||
# files from context and every retype differed from the stored bytes by a
|
||||
# character. Pinned three ways: the locked sentence order (the behaviour
|
||||
# the E2E and the persona depend on), the byte-identity of every build the
|
||||
# flag does NOT select, and the composition of the two display clauses
|
||||
# through the one "section minus its final </tools>" mechanism.
|
||||
|
||||
#: The clause's sentence anchors, in the locked order (task 04's work
|
||||
#: list): display contract → when to call → do-NOT-retype → the ```bor```
|
||||
#: placement syntax → the numbers belong to the server → the failure rule
|
||||
#: → the image pointer.
|
||||
SHOW_TEXT_ANCHORS: tuple[str, ...] = (
|
||||
"displays part of a document to the user EXACTLY as stored",
|
||||
"the user copies from what is displayed, not from your prose",
|
||||
"Call it whenever the user wants to SEE, COPY or RUN a file",
|
||||
"a quadlet, a config, a script, a unit file",
|
||||
"do NOT repeat the displayed lines in your answer",
|
||||
"may differ from the file by a character, and the user cannot tell",
|
||||
"write a locator line on its own: three backticks followed by `bor`",
|
||||
"the combined `source/path`, and optionally the line range (` 12-40`)",
|
||||
"Leave its body EMPTY — the app replaces it with the exact stored text",
|
||||
"Never type the file's content inside a `bor` block",
|
||||
"Never guess line numbers — use the numbers `grep` and `read` reported",
|
||||
"OMIT the range and display the whole document",
|
||||
"the numbers you display are the server's, never yours",
|
||||
"If `show_text` refuses, say what you could not display",
|
||||
"do not reconstruct it from memory",
|
||||
"For an image use `show_image` instead",
|
||||
)
|
||||
|
||||
|
||||
def test_show_text_clause_carries_every_locked_rule_in_order() -> None:
|
||||
"""Every sentence of the contract is present, in the locked order, and
|
||||
the clause is a clause — one paragraph, no ``<tools>`` markup of its
|
||||
own (the ``SHOW_IMAGE_CLAUSE`` precedent)."""
|
||||
positions = [SHOW_TEXT_CLAUSE.index(anchor) for anchor in SHOW_TEXT_ANCHORS]
|
||||
assert positions == sorted(positions), SHOW_TEXT_ANCHORS
|
||||
assert "\n" not in SHOW_TEXT_CLAUSE
|
||||
assert SHOW_TEXT_CLAUSE.startswith("`show_text`")
|
||||
assert SHOW_TEXT_CLAUSE.endswith("instead.")
|
||||
assert "<tools>" not in SHOW_TEXT_CLAUSE and "</tools>" not in SHOW_TEXT_CLAUSE
|
||||
# The fence language is named as a token, never as a stray substring.
|
||||
assert re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", SHOW_TEXT_CLAUSE)
|
||||
|
||||
|
||||
def test_high_prompt_default_stays_byte_identical_without_show_text() -> None:
|
||||
"""Completion pin: ``show_text`` defaults to ``False`` — the default
|
||||
build and the explicit ``show_text=False`` are byte-identical to each
|
||||
other and end with the untouched ``TOOLS_SECTION``, with no trace of
|
||||
the tool or the fence language (the sha pins in
|
||||
:mod:`tests.unit.test_prompt_lock` pass UNMODIFIED)."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
plain = build_high_prompt([doc])
|
||||
assert build_high_prompt([doc], show_text=False) == plain
|
||||
assert plain.endswith(TOOLS_SECTION)
|
||||
assert SHOW_TEXT_CLAUSE not in plain
|
||||
assert "show_text" not in plain
|
||||
assert not re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", plain)
|
||||
empty = build_high_prompt([])
|
||||
assert build_high_prompt([], show_text=False) == empty
|
||||
assert empty.endswith(TOOLS_SECTION)
|
||||
assert SHOW_TEXT_CLAUSE not in empty
|
||||
|
||||
|
||||
def test_high_prompt_show_text_clause_lands_inside_tools_after_the_copy() -> None:
|
||||
"""``show_text=True`` builds EXACTLY the toggle-off prompt with its
|
||||
final ``</tools>`` replaced by the clause (on its own line, after the
|
||||
existing three-tool copy) + the closing tag — the three-tool copy is
|
||||
byte-identical, ``TOOLS_SECTION`` is unedited, and the clause is the
|
||||
last thing the model reads."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
off = build_high_prompt([doc])
|
||||
on = build_high_prompt([doc], show_text=True)
|
||||
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
clause_start = on.index(SHOW_TEXT_CLAUSE)
|
||||
assert on[:clause_start] == off[: -len("</tools>")]
|
||||
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||
assert "Answer as soon as you have what you need." in on[:clause_start]
|
||||
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
|
||||
assert on.index("Answer as soon as you have what you need.") < clause_start
|
||||
assert clause_start < on.index("</tools>")
|
||||
assert on.count(SHOW_TEXT_CLAUSE) == 1
|
||||
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||
|
||||
|
||||
def test_high_prompt_display_clauses_compose_independently() -> None:
|
||||
"""The two display flags are independent (LOCKED A1): ``show_image``
|
||||
only reproduces the phase-124 bytes exactly, ``show_text`` only is the
|
||||
three-tool copy plus the text clause, both puts the image clause
|
||||
FIRST and the text clause last, and neither is byte-identical to
|
||||
pre-phase — no combination leaks the other's clause."""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
neither = build_high_prompt([doc])
|
||||
head = neither[: -len("</tools>")]
|
||||
image = build_high_prompt([doc], show_image=True)
|
||||
text = build_high_prompt([doc], show_text=True)
|
||||
both = build_high_prompt([doc], show_image=True, show_text=True)
|
||||
assert neither.endswith(TOOLS_SECTION)
|
||||
assert image == head + SHOW_IMAGE_CLAUSE + "\n</tools>"
|
||||
assert text == head + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert both == head + SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert SHOW_TEXT_CLAUSE not in image and SHOW_IMAGE_CLAUSE not in text
|
||||
assert both.index(SHOW_IMAGE_CLAUSE) < both.index(SHOW_TEXT_CLAUSE)
|
||||
for on in (image, text, both):
|
||||
assert on[: on.index("<tools>")] == neither[: on.index("<tools>")]
|
||||
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
|
||||
|
||||
|
||||
def test_high_prompt_empty_documents_show_text_clause_still_in_tools() -> None:
|
||||
"""The "(no documents matched…)" body + ``show_text=True``: the clause
|
||||
still lands inside ``<tools>`` — the grounded turn may still ``ls`` /
|
||||
``grep`` its way to a file and display it."""
|
||||
off = build_high_prompt([])
|
||||
on = build_high_prompt([], show_text=True)
|
||||
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
assert "(no documents matched — do not invent specifics)" in on
|
||||
assert on.index("<tools>") < on.index(SHOW_TEXT_CLAUSE) < on.index("</tools>")
|
||||
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
|
||||
|
||||
|
||||
def test_show_text_clause_is_last_for_every_high_shape() -> None:
|
||||
"""Steering + KB overview + folder lines + documents all present: the
|
||||
section order is UNCHANGED (``<relevance>`` → ``<knowledge_base>`` →
|
||||
``<tuning>`` → ``<documents>`` → ``<tools>``) and the text clause is
|
||||
still the last thing inside ``<tools>`` before the closing tag."""
|
||||
doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
|
||||
lines = ["Homelab/deploy/: k8s.md (1 doc)"]
|
||||
off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
|
||||
on = build_high_prompt(
|
||||
[doc],
|
||||
notes=["be concise"],
|
||||
kb_overview=OVERVIEW,
|
||||
folder_lines=lines,
|
||||
show_text=True,
|
||||
)
|
||||
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
|
||||
clause_start = on.index(SHOW_TEXT_CLAUSE)
|
||||
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
|
||||
for section in ("<relevance>", "<knowledge_base>", "<tuning>", "<documents>"):
|
||||
assert on.index(section) < on.index("<tools>")
|
||||
assert on.index("K8s deploy notes.") < on.index("<tools>")
|
||||
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
|
||||
assert SHOW_TEXT_CLAUSE not in off
|
||||
|
||||
|
||||
def test_show_text_clause_rides_only_the_high_prompt() -> None:
|
||||
"""Neither the tool name nor the fence language ever reaches the
|
||||
deflection prompt (phase 182's D9: a deflected turn has no tools, and
|
||||
resolving a locator there would leak KB text through a turn that just
|
||||
disclaimed it) — and ``build_deflect_prompt`` takes no such flag."""
|
||||
assert "show_text" not in inspect.signature(build_deflect_prompt).parameters
|
||||
for prompt in (
|
||||
build_deflect_prompt(["T1", "T2"]),
|
||||
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
|
||||
build_deflect_prompt([]),
|
||||
):
|
||||
assert SHOW_TEXT_CLAUSE not in prompt
|
||||
assert "show_text" not in prompt
|
||||
assert not re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", prompt)
|
||||
|
||||
|
||||
# ---------- phase 125 (task 05, LOCKED A4): the image context line ----------
|
||||
# An enriched image doc (tasks 03/04: non-NULL ``image_context`` in the
|
||||
# "Referenced in …" block shape) leads its ``<document>`` suggestion
|
||||
|
||||
@@ -63,6 +63,11 @@ from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
# Phase 182 (task 07): the ONE clipboard implementation moved to the shared
|
||||
# verbatim panel client; the share path delegates to it, so the fallback's
|
||||
# internals are pinned there (the owner-locked behavior is re-pinned, not
|
||||
# dropped).
|
||||
VERBATIM_JS = FRONTEND / "assets" / "verbatim.js"
|
||||
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
|
||||
# views are folded into the shell (index.html); both files deleted.
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
@@ -533,18 +538,36 @@ def test_share_current_chat_save_then_share_branch() -> None:
|
||||
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
)
|
||||
# The clipboard + fallback helpers live in app.js (the chat page's
|
||||
# copy of the per-page helper).
|
||||
# Phase 182 (task 07): the clipboard + fallback pair MOVED to
|
||||
# assets/verbatim.js so the answer's verbatim panels and ordinary code
|
||||
# blocks share ONE degradation with the share link. The owner-locked
|
||||
# behavior is unchanged and re-pinned on its new home: the same host (the
|
||||
# composer, beside the status line — it rides the delegation), the same
|
||||
# transient `.share-link-fallback` field, the same select-on-focus, the
|
||||
# same XSS contract (textContent only), the same title string.
|
||||
copy = _fn(js, "copyShareLinkWithFallback")
|
||||
assert "navigator.clipboard.writeText(absoluteUrl)" in copy
|
||||
assert 'field.className = "share-link-fallback"' in copy
|
||||
assert "field.href = absoluteUrl" in copy
|
||||
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
|
||||
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy, (
|
||||
"select-on-focus — the field-like behavior"
|
||||
assert "copyTextWithFallback(" in copy, (
|
||||
"the share path delegates — ONE clipboard implementation"
|
||||
)
|
||||
assert "composer.appendChild(field)" in copy, "near the status line (the composer)"
|
||||
sel = _fn(js, "selectAllInField")
|
||||
assert "absoluteUrl," in copy, "the shared URL is what gets copied"
|
||||
assert "composer," in copy, "near the status line (the composer)"
|
||||
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in copy, (
|
||||
"the owner-locked field title rides the delegation byte-identically"
|
||||
)
|
||||
ver = VERBATIM_JS.read_text(encoding="utf-8")
|
||||
impl = _fn(ver, "copyTextWithFallback")
|
||||
assert "await navigator.clipboard.writeText(text)" in impl
|
||||
assert 'field.className = "share-link-fallback"' in impl
|
||||
assert "field.href = text" in impl, "a URL field carries the href"
|
||||
assert "field.textContent = text" in impl, "XSS contract: textContent only"
|
||||
assert (
|
||||
'field.addEventListener("focus", () => selectAllInFallbackField(field))' in impl
|
||||
), "select-on-focus — the field-like behavior"
|
||||
assert "host.appendChild(field)" in impl, "appended to the caller's host"
|
||||
assert (
|
||||
'document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in impl
|
||||
), "one field at a time (a new offer replaces the old)"
|
||||
sel = _fn(ver, "selectAllInFallbackField")
|
||||
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ report).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -39,6 +41,7 @@ from app.schemas import (
|
||||
SavedChatUpdate,
|
||||
SourceRef,
|
||||
ToolCall,
|
||||
VerbatimBlockRef,
|
||||
)
|
||||
|
||||
# --- boundary constants (phase 83, A1 — mirror sources in app/schemas.py) ---
|
||||
@@ -1136,3 +1139,106 @@ def test_segments_alias_is_the_discriminated_union() -> None:
|
||||
assert isinstance(thinking, ChatThinkingSegment)
|
||||
tool = adapter.validate_python({"kind": "tool", "name": "ls"})
|
||||
assert isinstance(tool, ChatToolSegment)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 182 (task 05): VerbatimBlockRef + the saved record's answer_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _verbatim_block(**overrides: Any) -> dict[str, Any]:
|
||||
"""One resolved block — the done frame's item shape."""
|
||||
block: dict[str, Any] = {
|
||||
"source": "Homelab",
|
||||
"path": "quadlet/gufo.container",
|
||||
"title": "gufo.container",
|
||||
"start_line": 3,
|
||||
"end_line": 9,
|
||||
"total_lines": 21,
|
||||
"text": "[Unit]\nDescription=gufo",
|
||||
"truncated": False,
|
||||
"locator": "Homelab/quadlet/gufo.container#3-9",
|
||||
}
|
||||
block.update(overrides)
|
||||
return block
|
||||
|
||||
|
||||
def test_verbatim_block_ref_round_trips_every_field() -> None:
|
||||
"""Every field of the frame's item validates and serializes back
|
||||
unchanged (the client matches on ``locator`` and renders the
|
||||
SERVER's ``text`` + extent, so none of them may be dropped)."""
|
||||
block = VerbatimBlockRef.model_validate(_verbatim_block())
|
||||
assert block.model_dump() == _verbatim_block()
|
||||
|
||||
|
||||
def test_verbatim_block_ref_carries_the_phase_83_text_cap() -> None:
|
||||
"""``text`` is capped at the SAME 600 000 as the answer text (so an
|
||||
operator raising ``BOR_SHOW_TEXT_MAX_CHARS`` never hits the wire
|
||||
limit), and one character past it rejects."""
|
||||
assert len(VerbatimBlockRef.model_validate(_verbatim_block(text="x" * 600_000)).text) == (
|
||||
600_000
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
VerbatimBlockRef.model_validate(_verbatim_block(text="x" * 600_001))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["start_line", "end_line", "total_lines"])
|
||||
def test_verbatim_block_ref_refuses_negative_line_numbers(field: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
VerbatimBlockRef.model_validate(_verbatim_block(**{field: -1}))
|
||||
|
||||
|
||||
def test_saved_record_answer_text_round_trips_and_stays_optional() -> None:
|
||||
"""A brain record accepts ``answer_text`` (the phase-113 lesson — the
|
||||
done-time auto-save must never 422) and drops the key when it is
|
||||
``None`` or empty (A9: absent, never ``null``, never ``[]``)."""
|
||||
with_blocks = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "shown above", "answer_text": [_verbatim_block()]}
|
||||
)
|
||||
assert with_blocks.model_dump()["answer_text"] == [_verbatim_block()]
|
||||
|
||||
for value in (None, []):
|
||||
record = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "nothing shown", "answer_text": value}
|
||||
)
|
||||
assert "answer_text" not in record.model_dump()
|
||||
|
||||
|
||||
def test_saved_record_answer_text_list_is_capped() -> None:
|
||||
"""The list cap (the phase-83 SEC-05 style, the ``tools`` /
|
||||
``segments`` number): 51 blocks reject, 50 fit — far past the
|
||||
``show_text_max_blocks`` default of 5."""
|
||||
blocks = [_verbatim_block(locator=f"Homelab/a{i}.md") for i in range(51)]
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "too many", "answer_text": blocks}
|
||||
)
|
||||
assert len(
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "enough", "answer_text": blocks[:50]}
|
||||
).answer_text
|
||||
or []
|
||||
) == 50
|
||||
|
||||
|
||||
def test_saved_chat_total_budget_counts_verbatim_block_text() -> None:
|
||||
"""Phase 182 (task 05): the blocks are a SECOND byte channel on the
|
||||
anonymous save surface, so they count against the same
|
||||
8 000 000-character total (the SEC-05 invariant) — 15 records whose
|
||||
blocks total 8 400 000 reject even though their ``text`` is small."""
|
||||
turns = 15
|
||||
size = 560_000
|
||||
assert turns * size > TOTAL_BUDGET
|
||||
heavy = [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "short",
|
||||
"answer_text": [_verbatim_block(text="x" * size)],
|
||||
}
|
||||
for _ in range(turns)
|
||||
]
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
SavedChatCreate.model_validate({"messages": heavy})
|
||||
assert "8,000,000-character budget" in exc.value.errors()[0]["msg"]
|
||||
# 14 × 560 000 = 7 840 000 fits (plus the small text, still inside).
|
||||
assert len(SavedChatCreate.model_validate({"messages": heavy[:-1]}).messages) == 14
|
||||
|
||||
@@ -149,6 +149,45 @@ def test_docs_token_is_a_secret_live_overridable_row() -> None:
|
||||
assert "docs_token" not in NON_OVERRIDABLE
|
||||
|
||||
|
||||
def test_verbatim_display_rows_are_plain_live_overridable() -> None:
|
||||
"""Phase 182 (task 01, LOCKED A8): the three verbatim-display rows sit
|
||||
in ``Agent tools`` (the ``read_max_chars`` group, beside it) with the
|
||||
``Answer text`` / ``Show text max chars`` / ``Show text max blocks``
|
||||
labels and are plain — not ``secret`` (a bool and two ints), not
|
||||
``restart_required`` (the chat request path reads them per turn via
|
||||
``get_settings()`` — live), overridable (the overlay is generic over
|
||||
``bool``/``int`` fields). ``answer_text`` is the master switch and the
|
||||
two budgets accept ``0`` (inert) — a negative value fails startup, not
|
||||
the panel."""
|
||||
for key, label in (
|
||||
("answer_text", "Answer text"),
|
||||
("show_text_max_chars", "Show text max chars"),
|
||||
("show_text_max_blocks", "Show text max blocks"),
|
||||
):
|
||||
meta = CATALOG[key]
|
||||
assert meta.group == "Agent tools"
|
||||
assert meta.label == label
|
||||
assert meta.secret is False
|
||||
assert meta.restart_required is False
|
||||
assert meta.overridable is True
|
||||
assert key not in SECRETS
|
||||
assert key not in RESTART_REQUIRED
|
||||
assert key not in NON_OVERRIDABLE
|
||||
|
||||
|
||||
def test_verbatim_display_field_types() -> None:
|
||||
"""Phase 182 (task 01): the master switch is a ``bool`` and the two
|
||||
budgets are ``int``s with the LOCKED A8 defaults (``True`` /
|
||||
``12000`` / ``5``) — the types the panel renders its input for and the
|
||||
overlay coerces from the stored raw string."""
|
||||
assert Settings.model_fields["answer_text"].annotation is bool
|
||||
assert Settings.model_fields["show_text_max_chars"].annotation is int
|
||||
assert Settings.model_fields["show_text_max_blocks"].annotation is int
|
||||
assert Settings.model_fields["answer_text"].default is True
|
||||
assert Settings.model_fields["show_text_max_chars"].default == 12_000
|
||||
assert Settings.model_fields["show_text_max_blocks"].default == 5
|
||||
|
||||
|
||||
def test_labels_nonempty_and_unique() -> None:
|
||||
labels = [meta.label for meta in CATALOG.values()]
|
||||
assert all(label.strip() for label in labels), "empty label in the catalogue"
|
||||
|
||||
@@ -139,21 +139,31 @@ def test_shared_html_identical_header_guest_safe() -> None:
|
||||
|
||||
def test_shared_html_scripts_and_no_controls() -> None:
|
||||
"""Script load order (the house pattern): brand.js classic FIRST,
|
||||
the classic markdown renderer second, the shared.js module last —
|
||||
ALL with ABSOLUTE /assets/ paths: the page is served from the
|
||||
the classic markdown renderer second, the classic verbatim panel client
|
||||
third (phase 182 task 07 — it must be a global BEFORE the module, and
|
||||
AFTER markdown.js whose placeholders it hydrates), the shared.js module
|
||||
last — ALL with ABSOLUTE /assets/ paths: the page is served from the
|
||||
NESTED /shared/<token> route, where a relative "assets/…" ref
|
||||
would resolve to /shared/assets/… and 404. NO direct header.js
|
||||
<script> tag (single-evaluation design — shared.js imports it
|
||||
relatively). Zero controls (owner-locked): no document-modal, no
|
||||
composer, no Send/Save/Share/New chat buttons, no form element,
|
||||
and the ONLY <button>s on the page are the shared bar's own (the
|
||||
hamburger + the two sign-out copies)."""
|
||||
and the ONLY <button>s the page itself ships are the shared bar's own
|
||||
(the hamburger + the two sign-out copies). Phase 182's narrow exception
|
||||
(the panel's Copy button + its plain viewer link) is injected by the
|
||||
SHARED verbatim.js client, not by this markup, and is pinned in
|
||||
tests/unit/test_frontend_verbatim_blocks.py."""
|
||||
html = _html()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["/assets/brand.js", "/assets/markdown.js", "/assets/shared.js"], (
|
||||
f"shared.html must load brand.js + markdown.js (classic, absolute "
|
||||
f"/assets/ paths — the nested route breaks relative refs) and the "
|
||||
f"shared.js module, in that order, got {srcs}"
|
||||
assert srcs == [
|
||||
"/assets/brand.js",
|
||||
"/assets/markdown.js",
|
||||
"/assets/verbatim.js",
|
||||
"/assets/shared.js",
|
||||
], (
|
||||
f"shared.html must load brand.js + markdown.js + verbatim.js (classic, "
|
||||
f"absolute /assets/ paths — the nested route breaks relative refs) and "
|
||||
f"the shared.js module, in that order, got {srcs}"
|
||||
)
|
||||
# The stylesheet too (a relative href would break the same way).
|
||||
assert '<link rel="stylesheet" href="/assets/styles.css">' in html
|
||||
@@ -289,7 +299,18 @@ def test_render_shared_message_record_shape() -> None:
|
||||
js = _js()
|
||||
body = _fn(js, "renderSharedMessage")
|
||||
assert 'addSharedMessage("user", renderMarkdown(m.text))' in body
|
||||
assert 'addSharedMessage("brain", renderMarkdown(m.text))' in body
|
||||
# Phase 182 (task 07): the brain renderer opts INTO the verbatim pass
|
||||
# (the chat page's restore does the same — parity), and hydrates the
|
||||
# placeholders + appends the unlocated blocks from the record's
|
||||
# `answer_text` before the source chips. The user renderer stays
|
||||
# one-argument.
|
||||
assert 'addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }))' in body
|
||||
assert "hydrateVerbatim(" in body, "the shared page hydrates (parity)"
|
||||
assert "appendUnlocatedVerbatim(" in body
|
||||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||||
assert body.index("addAnswerImage(") < body.index("hydrateVerbatim(") < body.index(
|
||||
"addSources(wrap, m.sources)"
|
||||
), "the panel band matches the chat page: image, panels, then the chips"
|
||||
assert "renderMarkdown" in body, "the escape-first global renderer"
|
||||
assert "function renderMarkdown" not in js, (
|
||||
"shared.js must NOT define its own renderer — markdown.js is the one copy"
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Unit: the phase-182 verbatim resolver (``app/rag/verbatim.py``, task 02).
|
||||
|
||||
The module is the single source of truth for "which exact lines of which
|
||||
indexed document, and what are their bytes", so the suite covers its three
|
||||
layers: the pure grammar (:func:`canonical_locator`, :func:`fences_in`),
|
||||
the pure slicing (:func:`slice_lines`, :func:`cap_slice`), and the
|
||||
resolver (:func:`document_by_identity`, :func:`block_for`,
|
||||
:func:`resolve_answer_blocks`).
|
||||
|
||||
Session-fixture style follows the agent suite (``tests/unit/test_agent.py``):
|
||||
DETACHED :class:`~app.models.Document` rows plus a stand-in session for the
|
||||
one bare ``(source, path)`` lookup — the resolver's SQL is a single
|
||||
``WHERE`` on the table's unique pair, so a database adds nothing the
|
||||
contract needs (and the suite runs without the stack up).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import app.rag.verbatim as verbatim_module
|
||||
from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.rag.verbatim import (
|
||||
LOCATOR_LANG,
|
||||
SliceResult,
|
||||
VerbatimBlock,
|
||||
block_for,
|
||||
canonical_locator,
|
||||
cap_slice,
|
||||
document_by_identity,
|
||||
fences_in,
|
||||
resolve_answer_blocks,
|
||||
slice_lines,
|
||||
)
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _doc(
|
||||
source: str = "homelab",
|
||||
path: str = "a.md",
|
||||
title: str = "The Title",
|
||||
content: str = "one\ntwo\nthree",
|
||||
*,
|
||||
is_image: bool = False,
|
||||
) -> Document:
|
||||
"""A DETACHED row — the resolver reads ``source``/``path``/``title``/
|
||||
``content``/``is_image`` and nothing else, so no session is needed."""
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/srv/sources/{source}/{path}",
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
is_image=is_image,
|
||||
)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Stand-in session for :func:`document_by_identity`'s single lookup.
|
||||
|
||||
Records every ``(source, path)`` pair it was asked for, so a test can
|
||||
assert the FIRST-slash split reaches the ``WHERE`` clause verbatim —
|
||||
including the traversal-shaped identity, which is simply a pair no row
|
||||
has.
|
||||
"""
|
||||
|
||||
def __init__(self, docs: list[Document]) -> None:
|
||||
self.docs = {(d.source, d.path): d for d in docs}
|
||||
self.lookups: list[tuple[str, str]] = []
|
||||
|
||||
def scalar(self, stmt: Select[tuple[Document]]) -> Document | None:
|
||||
params = stmt.compile().params
|
||||
pair = (str(params["source_1"]), str(params["path_1"]))
|
||||
self.lookups.append(pair)
|
||||
return self.docs.get(pair)
|
||||
|
||||
|
||||
def _session(docs: list[Document]) -> _FakeDB:
|
||||
return _FakeDB(docs)
|
||||
|
||||
|
||||
def _real(session: _FakeDB) -> Session:
|
||||
return cast("Session", session)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# canonical_locator — the tolerant grammar in, ONE canonical string out
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_whole_document_locator_is_itself() -> None:
|
||||
assert canonical_locator("homelab/a.md") == "homelab/a.md"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"homelab/a.md 12-40",
|
||||
"homelab/a.md:12-40",
|
||||
"homelab/a.md#12-40",
|
||||
"homelab/a.md#L12-L40",
|
||||
"homelab/a.md L12-L40",
|
||||
"homelab/a.md 12\u201340", # en dash, the grep-style range
|
||||
"homelab/a.md 12 - 40 ", # stray spaces around the dash
|
||||
],
|
||||
)
|
||||
def test_every_range_spelling_canonicalizes(raw: str) -> None:
|
||||
assert canonical_locator(raw) == "homelab/a.md#12-40"
|
||||
|
||||
|
||||
def test_bare_line_number_is_one_line_block() -> None:
|
||||
assert canonical_locator("homelab/a.md 12") == "homelab/a.md#12-12"
|
||||
|
||||
|
||||
def test_identity_with_slashes_and_case_survives_untouched() -> None:
|
||||
"""Paths are case-sensitive row values; only the FIRST slash splits."""
|
||||
assert canonical_locator("Homelab/Notes/Deep/a.md") == "Homelab/Notes/Deep/a.md"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"a.md", # no source
|
||||
"",
|
||||
" ",
|
||||
"homelab/a.md 0-5", # non-positive bound
|
||||
"homelab/a.md 40-12", # start > end
|
||||
"homelab/a.md x-y", # non-numeric bound
|
||||
"homelab/a.md 12-40 extra", # trailing junk after the range
|
||||
"bor homelab/a.md", # the caller left the language token in
|
||||
"bor",
|
||||
"homelab/a.md#", # dangling separator, no range
|
||||
"homelab/a.md:",
|
||||
],
|
||||
)
|
||||
def test_non_locators_are_refused(raw: str) -> None:
|
||||
assert canonical_locator(raw) is None
|
||||
|
||||
|
||||
def test_whitespace_bearing_path_with_a_range_is_refused() -> None:
|
||||
"""The documented limit of the tolerant grammar: the fence's info
|
||||
string is ONE whitespace-free identity plus the range."""
|
||||
assert canonical_locator("homelab/my notes/a.md 12-40") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# fences_in — the raw locator strings, in answer order
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_fence_yields_its_raw_locator() -> None:
|
||||
answer = "Before.\n```bor homelab/a.md#12-40\nanything the model typed\n```\nAfter."
|
||||
assert fences_in(answer) == ["homelab/a.md#12-40"]
|
||||
|
||||
|
||||
def test_fences_come_back_in_answer_order_and_deduped_first() -> None:
|
||||
answer = (
|
||||
"```bor homelab/b.md\nb\n```\n"
|
||||
"words\n"
|
||||
"```bor homelab/a.md\na\n```\n"
|
||||
"```bor homelab/b.md\nb again\n```\n"
|
||||
)
|
||||
assert fences_in(answer) == ["homelab/b.md", "homelab/a.md"]
|
||||
|
||||
|
||||
def test_bare_language_fence_is_an_ordinary_code_block() -> None:
|
||||
"""A `` ```bor `` with no path is NOT a locator — and it must not
|
||||
swallow the real fence that follows it."""
|
||||
answer = "```bor\nplain code\n```\n```bor homelab/a.md\na\n```"
|
||||
assert fences_in(answer) == ["homelab/a.md"]
|
||||
|
||||
|
||||
def test_unclosed_fence_contributes_nothing() -> None:
|
||||
"""Defensive: a half-streamed answer never resolves a partial locator."""
|
||||
assert fences_in("intro\n```bor homelab/a.md#12-40\nhalf streamed") == []
|
||||
|
||||
|
||||
def test_non_bor_fences_are_ignored() -> None:
|
||||
answer = "```python\nx = 1\n```\nplain text, no locator"
|
||||
assert fences_in(answer) == []
|
||||
|
||||
|
||||
def test_nested_backticks_end_the_fence_first() -> None:
|
||||
"""Documented limitation, the renderer's rule: the fence closes at the
|
||||
FIRST closer, so text after it is only prose unless it opens a fence."""
|
||||
answer = "```bor homelab/a.md\n```\nor ```bor homelab/b.md``` inline\n```"
|
||||
assert fences_in(answer) == ["homelab/a.md"]
|
||||
|
||||
|
||||
def test_indented_fence_opens() -> None:
|
||||
assert fences_in(" ```bor homelab/a.md\na\n ```") == ["homelab/a.md"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# slice_lines — the one shared coordinate system
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_TWELVE_LINES = "\n".join(f"line {n}" for n in range(1, 13))
|
||||
|
||||
|
||||
def test_slice_whole_document() -> None:
|
||||
result = slice_lines(_TWELVE_LINES)
|
||||
assert result == SliceResult(
|
||||
text=_TWELVE_LINES, first_line=1, last_line=12, total_lines=12, past_end=False
|
||||
)
|
||||
|
||||
|
||||
def test_slice_offset_only_reads_to_the_end() -> None:
|
||||
result = slice_lines(_TWELVE_LINES, offset=10)
|
||||
assert result.text == "line 10\nline 11\nline 12"
|
||||
assert (result.first_line, result.last_line, result.total_lines) == (10, 12, 12)
|
||||
assert result.past_end is False
|
||||
|
||||
|
||||
def test_slice_limit_only_starts_at_line_one() -> None:
|
||||
result = slice_lines(_TWELVE_LINES, limit=2)
|
||||
assert result.text == "line 1\nline 2"
|
||||
assert (result.first_line, result.last_line) == (1, 2)
|
||||
|
||||
|
||||
def test_slice_offset_and_limit() -> None:
|
||||
result = slice_lines(_TWELVE_LINES, offset=12, limit=1)
|
||||
assert result.text == "line 12"
|
||||
assert (result.first_line, result.last_line) == (12, 12)
|
||||
|
||||
|
||||
def test_overshooting_limit_clamps_to_the_total() -> None:
|
||||
result = slice_lines(_TWELVE_LINES, offset=11, limit=100)
|
||||
assert result.text == "line 11\nline 12"
|
||||
assert (result.first_line, result.last_line, result.past_end) == (11, 12, False)
|
||||
|
||||
|
||||
def test_offset_past_the_end_refuses_with_the_true_total() -> None:
|
||||
result = slice_lines(_TWELVE_LINES, offset=13, limit=5)
|
||||
assert result.past_end is True
|
||||
assert result.text == ""
|
||||
assert result.total_lines == 12
|
||||
|
||||
|
||||
def test_empty_document_is_past_end() -> None:
|
||||
result = slice_lines("")
|
||||
assert result == SliceResult(
|
||||
text="", first_line=1, last_line=0, total_lines=0, past_end=True
|
||||
)
|
||||
|
||||
|
||||
def test_limit_below_one_is_an_empty_past_end_slice() -> None:
|
||||
"""A contract violation from the caller: refuse loudly-by-shape, never
|
||||
raise (the refusal text belongs to the caller)."""
|
||||
result = slice_lines(_TWELVE_LINES, offset=3, limit=0)
|
||||
assert result.past_end is True
|
||||
assert result.text == ""
|
||||
assert result.total_lines == 12
|
||||
|
||||
|
||||
def test_line_numbers_agree_with_splitlines() -> None:
|
||||
"""The grep → read(offset) coordinate system: the line ``grep`` calls
|
||||
``N`` is what ``offset=N, limit=1`` returns, byte for byte."""
|
||||
content = "# header\n\ntext one\n indented\nlast"
|
||||
lines = content.splitlines()
|
||||
for number, expected in enumerate(lines, start=1):
|
||||
result = slice_lines(content, offset=number, limit=1)
|
||||
assert result.text == expected
|
||||
assert (result.first_line, result.last_line) == (number, number)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# cap_slice — the character budget at a LINE boundary
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cap_under_budget_returns_the_text_untouched() -> None:
|
||||
text, last, truncated = cap_slice("one\ntwo\nthree", 1, 100)
|
||||
assert (text, last, truncated) == ("one\ntwo\nthree", 3, False)
|
||||
|
||||
|
||||
def test_cap_under_budget_offsets_from_the_slices_first_line() -> None:
|
||||
_text, last, truncated = cap_slice("ten\neleven", 10, 100)
|
||||
assert (last, truncated) == (11, False)
|
||||
|
||||
|
||||
def test_cap_empty_text_reports_the_first_line() -> None:
|
||||
assert cap_slice("", 7, 100) == ("", 7, False)
|
||||
|
||||
|
||||
def test_cap_counts_a_slice_that_ends_on_a_blank_line() -> None:
|
||||
"""Phase 182 (task 05) defect pin: a slice whose LAST line is blank
|
||||
legitimately ends with ``"\\n"`` (that empty line IS part of the text
|
||||
shown), so the extent counts it. ``splitlines()`` drops the trailing
|
||||
empty element — which made the caption claim one line fewer than the
|
||||
block displays AND made the canonical locator disagree with the same
|
||||
range resolved through any other entry point (the tool branch), so the
|
||||
same block showed twice."""
|
||||
text, last, truncated = cap_slice("one\ntwo\n", 4, 100)
|
||||
assert (text, last, truncated) == ("one\ntwo\n", 6, False)
|
||||
|
||||
|
||||
def test_cap_cuts_on_a_line_boundary_and_appends_the_shared_marker() -> None:
|
||||
text, last, truncated = cap_slice("one\ntwo\nthree", 1, 9) # line 3 is cut
|
||||
assert truncated is True
|
||||
assert text == f"one\ntwo\n{TRUNCATION_MARKER}"
|
||||
# The marker is not content: the range stops at the last WHOLE line.
|
||||
assert last == 2
|
||||
|
||||
|
||||
def test_cap_landing_exactly_on_a_line_end_keeps_that_line() -> None:
|
||||
"""The budget's edge IS the line's terminator — the line fits."""
|
||||
text, last, truncated = cap_slice("one\ntwo\nthree", 1, 7) # exactly "one\ntwo"
|
||||
assert truncated is True
|
||||
assert text == f"one\ntwo\n{TRUNCATION_MARKER}"
|
||||
assert last == 2
|
||||
|
||||
|
||||
def test_cap_never_claims_a_line_whose_terminator_falls_outside_the_budget() -> None:
|
||||
text, last, truncated = cap_slice("1\n22\n333\n4444", 1, 7) # "1\n22\n33"
|
||||
assert truncated is True
|
||||
assert text == f"1\n22\n{TRUNCATION_MARKER}"
|
||||
assert last == 2
|
||||
|
||||
|
||||
def test_cap_shorter_than_the_first_line_cuts_it_mid_way() -> None:
|
||||
"""The one documented sub-line cut — and it still ends with the marker."""
|
||||
text, last, truncated = cap_slice("abcdefghij\nsecond", 4, 4)
|
||||
assert truncated is True
|
||||
assert text == f"abcd\n{TRUNCATION_MARKER}"
|
||||
assert last == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [0, -1])
|
||||
def test_cap_zero_or_negative_is_a_loud_mis_wiring(cap: int) -> None:
|
||||
with pytest.raises(ValueError, match="cap_slice"):
|
||||
cap_slice("anything", 1, cap)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# document_by_identity — the one bare lookup
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_identity_lookup_hits() -> None:
|
||||
doc = _doc()
|
||||
session = _session([doc])
|
||||
assert document_by_identity(_real(session), "homelab/a.md") is doc
|
||||
assert session.lookups == [("homelab", "a.md")]
|
||||
|
||||
|
||||
def test_identity_lookup_misses() -> None:
|
||||
session = _session([_doc()])
|
||||
assert document_by_identity(_real(session), "homelab/missing.md") is None
|
||||
|
||||
|
||||
def test_traversal_shaped_identity_is_simply_a_row_that_does_not_exist() -> None:
|
||||
session = _session([_doc()])
|
||||
assert document_by_identity(_real(session), "../../etc/passwd") is None
|
||||
assert session.lookups == [("..", "../etc/passwd")]
|
||||
|
||||
|
||||
def test_first_slash_splits_identity_when_the_path_has_slashes() -> None:
|
||||
doc = _doc(path="notes/deep/a.md")
|
||||
session = _session([doc])
|
||||
assert document_by_identity(_real(session), "homelab/notes/deep/a.md") is doc
|
||||
assert session.lookups == [("homelab", "notes/deep/a.md")]
|
||||
|
||||
|
||||
def test_identity_without_a_slash_never_touches_the_database() -> None:
|
||||
"""A bare source name is never a document (the agent's contract)."""
|
||||
session = _session([_doc()])
|
||||
assert document_by_identity(_real(session), "homelab") is None
|
||||
assert session.lookups == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# block_for — the block a locator names
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_block_whole_document() -> None:
|
||||
doc = _doc(content="one\ntwo\nthree")
|
||||
block = block_for(_real(_session([doc])), "homelab/a.md", cap=1000)
|
||||
assert block is not None
|
||||
assert (block.source, block.path, block.title) == ("homelab", "a.md", "The Title")
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (1, 3, 3)
|
||||
assert block.text == "one\ntwo\nthree"
|
||||
assert block.truncated is False
|
||||
assert block.locator == "homelab/a.md"
|
||||
|
||||
|
||||
def test_block_with_a_named_range_carries_it_in_the_locator() -> None:
|
||||
doc = _doc(content="\n".join(f"line {n}" for n in range(1, 13)))
|
||||
block = block_for(
|
||||
_real(_session([doc])), "homelab/a.md", offset=12, limit=2, cap=1000
|
||||
)
|
||||
assert block is not None
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (12, 12, 12)
|
||||
assert block.text == "line 12"
|
||||
assert block.locator == "homelab/a.md#12-12"
|
||||
|
||||
|
||||
def test_block_of_an_image_document_is_refused() -> None:
|
||||
"""Image display belongs to ``show_image`` (LOCKED A6)."""
|
||||
doc = _doc(is_image=True)
|
||||
assert block_for(_real(_session([doc])), "homelab/a.md", cap=1000) is None
|
||||
|
||||
|
||||
def test_block_of_an_unknown_path_is_refused() -> None:
|
||||
assert block_for(_real(_session([_doc()])), "homelab/nope.md", cap=1000) is None
|
||||
|
||||
|
||||
def test_block_of_a_past_end_range_is_refused() -> None:
|
||||
doc = _doc(content="one\ntwo")
|
||||
assert block_for(_real(_session([doc])), "homelab/a.md", offset=9, cap=1000) is None
|
||||
|
||||
|
||||
def test_capped_blocks_range_describes_the_lines_actually_shown() -> None:
|
||||
doc = _doc(content="1\n22\n333\n4444")
|
||||
block = block_for(_real(_session([doc])), "homelab/a.md", cap=6) # cuts line 3
|
||||
assert block is not None
|
||||
assert block.truncated is True
|
||||
assert block.text == f"1\n22\n{TRUNCATION_MARKER}"
|
||||
assert (block.start_line, block.end_line, block.total_lines) == (1, 2, 4)
|
||||
assert block.locator == "homelab/a.md#1-2"
|
||||
|
||||
|
||||
def test_block_reuses_a_callers_resolved_document() -> None:
|
||||
"""The ``show_text`` branch already resolved the row (with suggestions);
|
||||
the block build must not look it up twice."""
|
||||
db = MagicMock()
|
||||
block = block_for(cast("Session", db), "homelab/a.md", cap=1000, doc=_doc())
|
||||
assert block is not None
|
||||
assert block.locator == "homelab/a.md"
|
||||
db.scalar.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# resolve_answer_blocks — the done-time fusion
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_TEN = "\n".join(f"line {n}" for n in range(1, 11))
|
||||
|
||||
|
||||
def _answer(*locators: str) -> str:
|
||||
return "".join(f"```bor {loc}\nmodel text\n```\n" for loc in locators)
|
||||
|
||||
|
||||
def _shown(locator: str, *, first: int = 1, last: int = 3) -> VerbatimBlock:
|
||||
return VerbatimBlock(
|
||||
source="homelab",
|
||||
path="a.md",
|
||||
title="The Title",
|
||||
start_line=first,
|
||||
end_line=last,
|
||||
total_lines=10,
|
||||
text="one\ntwo\nthree",
|
||||
locator=locator,
|
||||
)
|
||||
|
||||
|
||||
def test_no_fences_and_no_shown_blocks_is_empty() -> None:
|
||||
assert resolve_answer_blocks(
|
||||
_real(_session([])), "a plain answer", [], _settings()
|
||||
) == []
|
||||
|
||||
|
||||
def test_a_fence_resolves_without_any_tool_call() -> None:
|
||||
doc = _doc(content=_TEN)
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([doc])),
|
||||
_answer("homelab/a.md#4-5"),
|
||||
[],
|
||||
_settings(),
|
||||
)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].locator == "homelab/a.md#4-5"
|
||||
assert blocks[0].text == "line 4\nline 5"
|
||||
assert (blocks[0].start_line, blocks[0].end_line) == (4, 5)
|
||||
|
||||
|
||||
def test_shown_blocks_are_appended_when_the_answer_locates_nothing() -> None:
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([])),
|
||||
"no fence here",
|
||||
[_shown("homelab/a.md"), _shown("homelab/a.md#4-6", first=4, last=6)],
|
||||
_settings(),
|
||||
)
|
||||
assert [b.locator for b in blocks] == ["homelab/a.md", "homelab/a.md#4-6"]
|
||||
|
||||
|
||||
def test_fence_wins_over_the_same_locator_from_shown() -> None:
|
||||
"""ONE block per canonical locator, placed at the FENCE (the fence
|
||||
controls placement)."""
|
||||
doc = _doc(content=_TEN)
|
||||
shown = _shown("homelab/a.md#2-3", first=2, last=3)
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([doc])),
|
||||
"intro\n" + _answer("homelab/a.md#2-3") + "outro",
|
||||
[shown, _shown("homelab/a.md#2-3", first=2, last=3)],
|
||||
_settings(),
|
||||
)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].text == "line 2\nline 3"
|
||||
|
||||
|
||||
def test_capped_fence_dedupes_against_the_same_capped_shown_block() -> None:
|
||||
"""Phase 182 (task 05) defect pin: a char-capped fence carries a
|
||||
NARROWER locator than the one its fence wrote (the cut extent) — and
|
||||
that is exactly the key the ``show_text`` tool's same-range block
|
||||
carries, so the pair is ONE block, placed at the fence."""
|
||||
doc = _doc(content=_TEN)
|
||||
settings = _settings(show_text_max_chars=20)
|
||||
fenced = resolve_answer_blocks(
|
||||
_real(_session([doc])), _answer("homelab/a.md"), [], settings
|
||||
)
|
||||
assert len(fenced) == 1 and fenced[0].truncated
|
||||
assert fenced[0].locator == f"homelab/a.md#1-{fenced[0].end_line}"
|
||||
|
||||
shown = _shown(fenced[0].locator, last=fenced[0].end_line)
|
||||
fused = resolve_answer_blocks(
|
||||
_real(_session([doc])), _answer("homelab/a.md"), [shown], settings
|
||||
)
|
||||
assert [b.locator for b in fused] == [fenced[0].locator]
|
||||
|
||||
|
||||
def test_two_fences_keep_answer_order_then_the_unlocated_shown() -> None:
|
||||
second = _doc(path="b.md", title="B", content="bee\nbee two")
|
||||
first = _doc(path="a.md", content=_TEN)
|
||||
shown_a = _shown("homelab/a.md")
|
||||
shown_c = _shown("homelab/c.md")
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([first, second])),
|
||||
_answer("homelab/b.md", "homelab/a.md#9-10"),
|
||||
[shown_a, shown_c],
|
||||
_settings(),
|
||||
)
|
||||
# fences first, in answer order — then the shown blocks the answer
|
||||
# never located, in their original call order (the whole-document
|
||||
# ``homelab/a.md`` block is a DIFFERENT canonical locator from the
|
||||
# fenced ``homelab/a.md#9-10``, so it stays unlocated).
|
||||
assert [b.locator for b in blocks] == [
|
||||
"homelab/b.md",
|
||||
"homelab/a.md#9-10",
|
||||
"homelab/a.md",
|
||||
"homelab/c.md",
|
||||
]
|
||||
|
||||
|
||||
def test_block_budget_truncates_the_list() -> None:
|
||||
doc = _doc(content=_TEN)
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([doc])),
|
||||
_answer("homelab/a.md#1-2", "homelab/a.md#4-5"),
|
||||
[_shown("homelab/a.md#7-8", first=7, last=8)],
|
||||
_settings(show_text_max_blocks=1),
|
||||
)
|
||||
assert [b.locator for b in blocks] == ["homelab/a.md#1-2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"answer_text": False},
|
||||
{"show_text_max_blocks": 0},
|
||||
{"show_text_max_chars": 0},
|
||||
{"answer_text": False, "show_text_max_blocks": 0},
|
||||
{"answer_text": False, "show_text_max_chars": 0},
|
||||
{"show_text_max_blocks": 0, "show_text_max_chars": 0},
|
||||
],
|
||||
)
|
||||
def test_every_kill_switch_combination_is_inert(overrides: dict[str, Any]) -> None:
|
||||
doc = _doc(content=_TEN)
|
||||
assert (
|
||||
resolve_answer_blocks(
|
||||
_real(_session([doc])),
|
||||
_answer("homelab/a.md#4-5"),
|
||||
[_shown("homelab/a.md#7-8", first=7, last=8)],
|
||||
_settings(**overrides),
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_unresolvable_fence_is_simply_absent() -> None:
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([_doc(content=_TEN)])),
|
||||
_answer("elsewhere/nope.md"),
|
||||
[],
|
||||
_settings(),
|
||||
)
|
||||
assert blocks == []
|
||||
|
||||
|
||||
def test_image_document_fence_is_absent() -> None:
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([_doc(content=_TEN, is_image=True)])),
|
||||
_answer("homelab/a.md#2-3"),
|
||||
[],
|
||||
_settings(),
|
||||
)
|
||||
assert blocks == []
|
||||
|
||||
|
||||
def test_a_duplicated_fence_appears_once() -> None:
|
||||
doc = _doc(content=_TEN)
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([doc])),
|
||||
_answer("homelab/a.md#2-3") + "words\n" + _answer("homelab/a.md 2-3"),
|
||||
[],
|
||||
_settings(),
|
||||
)
|
||||
assert [b.locator for b in blocks] == ["homelab/a.md#2-3"]
|
||||
|
||||
|
||||
def test_malformed_fence_is_ignored_without_reserving_its_text() -> None:
|
||||
"""A locator the grammar refuses never reaches the resolver at all."""
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([_doc(content=_TEN)])),
|
||||
_answer("homelab/a.md 40-12"),
|
||||
[],
|
||||
_settings(),
|
||||
)
|
||||
assert blocks == []
|
||||
|
||||
|
||||
def test_a_loosened_grammar_still_refuses_non_numeric_bounds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The defensive branch: the range regex above guarantees digit bounds,
|
||||
but if it is ever loosened, :func:`canonical_locator` still refuses a
|
||||
non-numeric bound instead of raising out of ``int()``."""
|
||||
monkeypatch.setattr(
|
||||
verbatim_module,
|
||||
"_TRAILING_RANGE_RE",
|
||||
re.compile(r"[ :#](?P<range>[^ ]*)$"), # the separator, then ANY tail
|
||||
)
|
||||
assert canonical_locator("homelab/a.md:x-y") is None
|
||||
|
||||
|
||||
def test_the_character_cap_applies_to_fenced_blocks() -> None:
|
||||
doc = _doc(content=_TEN)
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(_session([doc])),
|
||||
_answer("homelab/a.md"),
|
||||
[],
|
||||
_settings(show_text_max_chars=20),
|
||||
)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].truncated is True
|
||||
assert blocks[0].text.endswith(TRUNCATION_MARKER)
|
||||
# 20 characters land exactly on the terminator of "line 3", so lines
|
||||
# 1-3 are whole and the caption stops at 3 (line 4 is cut).
|
||||
assert blocks[0].text == f"line 1\nline 2\nline 3\n{TRUNCATION_MARKER}"
|
||||
assert blocks[0].end_line == 3
|
||||
assert blocks[0].locator == "homelab/a.md#1-3"
|
||||
|
||||
|
||||
def test_locator_language_constant_is_shared() -> None:
|
||||
assert LOCATOR_LANG == "bor"
|
||||
|
||||
|
||||
def test_module_never_imports_the_agent() -> None:
|
||||
"""The one-way import rule, read off the AST (the docstring names the
|
||||
agent on purpose to explain the rule, so a substring check would
|
||||
prove nothing): ``agent.py`` imports this module's pure half; a
|
||||
reverse import would cycle."""
|
||||
tree = ast.parse(inspect.getsource(verbatim_module))
|
||||
imports: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
imports.extend(f"{node.module or ''}.{alias.name}" for alias in node.names)
|
||||
offending = [name for name in imports if name.startswith("app.rag.agent")]
|
||||
assert offending == []
|
||||
|
||||
|
||||
def test_the_fence_scan_stops_once_the_block_budget_is_spent() -> None:
|
||||
"""A fence-stuffed answer costs at most ``show_text_max_blocks``
|
||||
successful lookups: once the display budget is in, the scan stops —
|
||||
the truncation to the same number makes every later block
|
||||
unreachable, and the resolver runs on the request's event loop."""
|
||||
session = _session([_doc(content=_TEN), _doc(path="b.md", title="B", content="bee\ntwo")])
|
||||
blocks = resolve_answer_blocks(
|
||||
_real(session),
|
||||
_answer("homelab/a.md", "homelab/b.md", "homelab/a.md#2-3"),
|
||||
[],
|
||||
_settings(show_text_max_blocks=2),
|
||||
)
|
||||
assert [b.locator for b in blocks] == ["homelab/a.md", "homelab/b.md"]
|
||||
assert len(session.lookups) == 2
|
||||
Reference in New Issue
Block a user