phase: 182_verbatim_show_text
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**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:
2026-10-15 08:33:12 -04:00
parent 814a400e6d
commit b34b8ca672
104 changed files with 10852 additions and 203 deletions
+568 -34
View File
@@ -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