phase: 95_read_truncation_cap
All gates are green. Final verification is complete — no fixes were needed; the phase is fully implemented and passing. **Phase 95 final verification report** - Verified all 3 tasks already implemented (task files in `complete/`): read cap + notice + `ToolResultPiece` (task 01), `tool_result` SSE + live/saved/shared marker (task 02), E2E suite (task 03). - Code checks passed: `BOR_READ_MAX_CHARS` (default 128 000, validator), pinned `READ_TRUNCATION_NOTICE` + `TRUNCATION_MARKER`, byte-identical at/under-cap read, holder `read_truncations` (no `tool_calls` impact), piece order (after `tool`, before next round), `ChatToolResultEvent`, `ToolCall` back-compat fields, `app.js` live/restore handlers, `shared.js`, `.truncated-note` CSS, `.env.example` entry, mock-LLM echo script. - **Tests:** `uv run pytest` → 1966 passed; `uv run pytest --cov=app --cov-report=term-missing` → all green, TOTAL **99%** (>90% gate). - **E2E:** `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` → **3 passed** (frame order + live marker + LLM notice via echo; save→shared fidelity; short-read control). - **Regression (isolated):** `test_agent_document_tools` 4 ✓, `test_chat_history` 5 ✓, `test_share_chat` 4 ✓, `test_big_read_progress` 4 ✓, `test_stop_generation` 3 ✓. - **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. **Completion criteria:** ① over-cap read → first-cap-chars + marker + pinned notice — ✓ (unit-pinned: at-cap/cap+1/notice tests); ② user marker live/saved/shared — ✓ (E2E + frontend tests); ③ at/under cap byte-identical, no frame — ✓ (unit + control E2E); ④ top-2 `<documents>` retrieval untouched — ✓ (`app/rag/retriever.py` unmodified vs HEAD); ⑤ suite green, >90% coverage, ruff+pyright clean — ✓; ⑥ no completed-phase behavior change — ✓ (all gates green; commit left to harness per pass rules). - No defects found; no changes made this pass. Next pending phase: none in `todo/` (96 is the next free number).
This commit is contained in:
+205
-12
@@ -47,12 +47,21 @@ from app.models import Document, GitSource
|
||||
from app.rag import agent
|
||||
from app.rag.agent import (
|
||||
AGENT_TOOLS,
|
||||
READ_TRUNCATION_NOTICE,
|
||||
AgentHolder,
|
||||
MalformedReplyError,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece
|
||||
from app.rag.llm import (
|
||||
LLMClient,
|
||||
LLMError,
|
||||
RetryPiece,
|
||||
StreamPiece,
|
||||
ToolCallPiece,
|
||||
ToolResultPiece,
|
||||
)
|
||||
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
@@ -121,11 +130,12 @@ async def _run(
|
||||
settings: Settings,
|
||||
seed_docs: list[Document] | None = None,
|
||||
history: Sequence[dict[str, Any]] = (),
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
"""Consume one ``run_agent`` turn; *history* (phase 74) is the
|
||||
client's prior turns spliced between system and user (default
|
||||
``()`` — the pre-phase-74 two-message request)."""
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
``()`` — the pre-phase-74 two-message request). Phase 95: the loop
|
||||
may also yield a ``ToolResultPiece`` (a truncated ``read``)."""
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
cast("Session", None),
|
||||
@@ -192,15 +202,25 @@ def test_agent_tools_names_and_parameters() -> None:
|
||||
# section already carries (every refusal of a 12-call run was
|
||||
# ALREADY_IN_CONTEXT); the rule now leads the description instead
|
||||
# of sitting mid-paragraph, and the tool is framed as "only for
|
||||
# documents NOT already in <documents>".
|
||||
# documents NOT already in <documents>". Phase 95 (task 01): the
|
||||
# read-truncation sentence is inserted before the one-call-at-a-
|
||||
# time discipline clause (the discipline rule stays last, as in the
|
||||
# other two tools) — a capped read carries the TRUNCATED notice and
|
||||
# the `grep` follow-up (the pinned copy).
|
||||
assert read["description"] == (
|
||||
"Do not call this tool for a document already shown in "
|
||||
"the <documents> section, even when the user asks you to "
|
||||
"open or read it — its full text is already in your "
|
||||
"prompt; answer directly from it. Use it only to add a "
|
||||
"document NOT already in <documents> to your context, "
|
||||
"by its combined `source/path` string. Call one tool at "
|
||||
"a time — wait for this result before your next call."
|
||||
"by its combined `source/path` string. Very large "
|
||||
"documents are truncated: you receive the first part "
|
||||
"plus a TRUNCATED notice naming how many more characters "
|
||||
"exist — the notice is authoritative, the document did "
|
||||
"NOT end where it stopped. Follow it and use `grep` "
|
||||
"(pattern) to locate the rest — it searches the whole "
|
||||
"document. Call one tool at a time — wait for this "
|
||||
"result before your next call."
|
||||
)
|
||||
read_params = read["parameters"]
|
||||
assert read_params["type"] == "object"
|
||||
@@ -1519,6 +1539,177 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
|
||||
|
||||
# ---------- phase 95: the read cap (bounded reads, honest truncation) ----------
|
||||
|
||||
|
||||
def test_read_exactly_at_cap_is_byte_identical_and_untruncated(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 95 boundary: a document whose content length EQUALS the cap
|
||||
fits — read whole, byte-identical to the pre-phase-95 result (no
|
||||
marker, no notice, no holder entry, no ``ToolResultPiece``)."""
|
||||
cap = 20
|
||||
content = "x" * cap
|
||||
doc = _doc("S", "big.md", "Big", content)
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
# Byte-identical to today's read result (no marker, no notice).
|
||||
assert llm.requests[1][0][3]["content"] == "Document S/big.md:\n" + content
|
||||
assert TRUNCATION_MARKER not in llm.requests[1][0][3]["content"]
|
||||
# No truncation recorded, none surfaced to the loop.
|
||||
assert holder.read_truncations == []
|
||||
assert not any(isinstance(p, ToolResultPiece) for p in out)
|
||||
# Still a successful read.
|
||||
assert holder.read_docs == [doc]
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_read_at_cap_plus_one_truncates_with_marker_and_notice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 95 boundary: ONE char over the cap truncates — the first
|
||||
``cap`` chars + the shared :data:`TRUNCATION_MARKER` + the pinned
|
||||
grep-pointer notice (``{total}`` = the true length, ``{shown}`` = the
|
||||
cap), and the truncation is recorded on the holder. A truncated read
|
||||
is still a successful call (``tool_calls`` / ``read_docs`` as today)."""
|
||||
cap = 20
|
||||
content = "x" * (cap + 1)
|
||||
doc = _doc("S", "big.md", "Big", content)
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
expected = (
|
||||
"Document S/big.md:\n"
|
||||
+ content[:cap]
|
||||
+ "\n"
|
||||
+ TRUNCATION_MARKER
|
||||
+ "\n"
|
||||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=cap + 1)
|
||||
)
|
||||
assert llm.requests[1][0][3]["content"] == expected
|
||||
# (argument, chars_shown, chars_total) — the raw argument the tool
|
||||
# frame carries, the cap kept, the true length.
|
||||
assert holder.read_truncations == [("S/big.md", cap, cap + 1)]
|
||||
assert holder.read_docs == [doc] # still added to the context
|
||||
assert holder.tool_calls == 1 # still a counted, successful call
|
||||
|
||||
|
||||
def test_read_truncation_notice_is_pinned() -> None:
|
||||
"""Phase 95: the notice copy is pinned — it names the true length
|
||||
(``{total}``), the cap kept (``{shown}``), states the rest is NOT
|
||||
shown (the document did not end where it stopped), and points at
|
||||
``grep`` (which searches the whole document)."""
|
||||
assert READ_TRUNCATION_NOTICE.format(shown=100, total=250) == (
|
||||
"TRUNCATED — this document is 250 characters; only the first "
|
||||
"100 are in your context. The rest is NOT shown. Use grep "
|
||||
"(pattern) to locate what you need — grep searches the whole "
|
||||
"document."
|
||||
)
|
||||
|
||||
|
||||
def test_run_agent_yields_tool_result_after_tool_frame_before_next_round(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 95: ``run_agent`` yields exactly ONE ``ToolResultPiece`` per
|
||||
truncated read — AFTER the round's ``tool`` frame (the matching
|
||||
``ToolCallPiece``) and BEFORE the next model round (the answer
|
||||
pieces). It carries (argument, shown, total); ``argument`` is the
|
||||
same value the matching ``tool`` frame carries (the raw
|
||||
``source/path`` the model passed)."""
|
||||
cap = 20
|
||||
content = "y" * (cap + 5)
|
||||
doc = _doc("S", "big.md", "Big", content)
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
# The piece order: the read's ToolCallPiece, then the ToolResultPiece,
|
||||
# then the next round's content (the answer).
|
||||
assert isinstance(out[0], ToolCallPiece) and out[0].name == "read"
|
||||
piece = out[1]
|
||||
assert isinstance(piece, ToolResultPiece)
|
||||
assert isinstance(out[2], StreamPiece)
|
||||
assert piece.name == "read"
|
||||
assert piece.argument == "S/big.md"
|
||||
assert piece.truncated is True
|
||||
assert piece.chars_shown == cap
|
||||
assert piece.chars_total == cap + 5
|
||||
# Exactly one ToolResultPiece for the one truncated read.
|
||||
assert [p for p in out if isinstance(p, ToolResultPiece)] == [piece]
|
||||
|
||||
|
||||
def test_run_agent_short_read_yields_no_tool_result_piece(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 95: a read at or under the cap yields NO ``ToolResultPiece``
|
||||
— the non-truncated stream is byte-identical to the pre-phase-95
|
||||
one (just the ``ToolCallPiece`` + the answer)."""
|
||||
cap = 20
|
||||
content = "z" * cap # exactly at the cap → not truncated
|
||||
doc = _doc("S", "small.md", "Small", content)
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/small.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
assert not any(isinstance(p, ToolResultPiece) for p in out)
|
||||
assert holder.read_truncations == []
|
||||
# Order: the ToolCallPiece then the answer content (no piece between).
|
||||
assert isinstance(out[0], ToolCallPiece) and out[0].name == "read"
|
||||
assert isinstance(out[1], StreamPiece)
|
||||
|
||||
|
||||
def test_read_truncation_does_not_touch_refusal_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 95: the read refusal paths are untouched by the cap — a SEED
|
||||
document that is over the cap is still refused with
|
||||
``ALREADY_IN_CONTEXT`` (not truncated, nothing recorded, nothing
|
||||
counted), and an unknown path is still the no-document refusal (no
|
||||
content is read, so no truncation either)."""
|
||||
big = "B" * 5000 # far over the tiny cap below
|
||||
seed = _doc("S", "seed.md", "Seed", big)
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||||
monkeypatch.setattr(agent, "all_documents", lambda db: [])
|
||||
# (a) Reading the (over-cap) seed doc → ALREADY_IN_CONTEXT (refusal).
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/seed.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings(read_max_chars=100), seed_docs=[seed]))
|
||||
assert llm.requests[1][0][3]["content"] == agent.ALREADY_IN_CONTEXT
|
||||
assert holder.read_truncations == []
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
# (b) An unknown path → the no-document refusal (argument echoed),
|
||||
# even though a big doc could have truncated — no content is read.
|
||||
holder2 = AgentHolder()
|
||||
llm2 = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/missing.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm2, holder2, _settings(read_max_chars=100), seed_docs=[seed]))
|
||||
assert llm2.requests[1][0][3]["content"] == (
|
||||
"No document at 'S/missing.md' — check the ls output."
|
||||
)
|
||||
assert holder2.read_truncations == []
|
||||
assert holder2.tool_calls == 0 and holder2.read_docs == []
|
||||
|
||||
|
||||
def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
@@ -2322,8 +2513,8 @@ def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyP
|
||||
)
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
|
||||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
with pytest.raises(LLMError, match="mid-stream drop"):
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
@@ -2384,8 +2575,8 @@ def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
llm = FailingLLM([([], LLMError("connection refused"))])
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
|
||||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
with pytest.raises(LLMError, match="connection refused"):
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
@@ -2434,7 +2625,9 @@ def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch)
|
||||
holder=holder,
|
||||
)
|
||||
|
||||
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
async def consumer() -> list[
|
||||
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
|
||||
]:
|
||||
return [p async for p in gen]
|
||||
|
||||
task = asyncio.ensure_future(consumer())
|
||||
|
||||
@@ -209,6 +209,32 @@ def test_agent_max_rounds_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
_settings()
|
||||
|
||||
|
||||
def test_read_max_chars_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 95: the agent ``read`` tool's result is capped at
|
||||
``BOR_READ_MAX_CHARS`` (default 128 000 chars ≈ 32k tokens — a
|
||||
quarter of the owner's 128k-token minimum context). Env-tunable in
|
||||
both directions."""
|
||||
monkeypatch.delenv("BOR_READ_MAX_CHARS", raising=False)
|
||||
assert _settings().read_max_chars == 128_000
|
||||
monkeypatch.setenv("BOR_READ_MAX_CHARS", "5000")
|
||||
assert _settings().read_max_chars == 5000
|
||||
# ``0`` is legal (every non-empty read truncates to the marker +
|
||||
# notice) — it is not a kill switch, so no lower-bound error.
|
||||
monkeypatch.setenv("BOR_READ_MAX_CHARS", "0")
|
||||
assert _settings().read_max_chars == 0
|
||||
|
||||
|
||||
def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A negative cap is a typo — it would slice from the END of the
|
||||
content (negative indexing) instead of failing, so the validator
|
||||
fails loudly at startup (the ``agent_max_rounds`` pattern)."""
|
||||
monkeypatch.setenv("BOR_READ_MAX_CHARS", "-1")
|
||||
with pytest.raises(ValidationError, match="read_max_chars"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
|
||||
``0``/``false`` turn the ``thinking`` SSE frames off."""
|
||||
|
||||
@@ -229,6 +229,24 @@ def test_tools_section_phase72_contract_clauses() -> None:
|
||||
assert "not a directory or file path" not in TOOLS_SECTION
|
||||
|
||||
|
||||
def test_tools_section_phase95_read_truncation_clause() -> None:
|
||||
"""Phase 95 (task 01): the ``read`` teaching gains exactly one line —
|
||||
very large documents are capped, a cut read returns the first part
|
||||
plus the TRUNCATED notice (the document did not end where it
|
||||
stopped), and ``grep`` is the follow-up (it searches the whole
|
||||
document). The ``ls``/``grep`` teaching is untouched (phase 94 owns
|
||||
``ls``) — the clause is pinned byte-for-byte in the constant."""
|
||||
assert (
|
||||
"Very large documents are capped: a cut read returns the first "
|
||||
"part plus a TRUNCATED notice — the document did not end where "
|
||||
"it stopped; use `grep` (pattern) to find the rest, it searches "
|
||||
"the whole document."
|
||||
) in TOOLS_SECTION
|
||||
# It rides the HIGH prompt and never the LOW (deflected) prompt.
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes")
|
||||
assert "Very large documents are capped" in build_high_prompt([doc])
|
||||
|
||||
|
||||
def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None:
|
||||
"""Phase 72/94: the contract clauses ride the HIGH prompt with the
|
||||
rest of the section and never leak into the LOW/deflection prompt
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Unit: the phase-95 (task 02) truncation-marker frontend contract.
|
||||
|
||||
No new Python app logic exists for the marker itself — the behavior
|
||||
lives in ``frontend/assets/app.js`` (the ``tool_result`` SSE branch +
|
||||
the ``toolAcc`` stamp + the phase-14 restore marker),
|
||||
``frontend/assets/shared.js`` (the shared page's local tool-line
|
||||
render) and ``frontend/assets/styles.css`` (the theme-neutral
|
||||
``.truncated-note`` rule). Like the other frontend-adjacent unit files
|
||||
(``test_frontend_tool_states.py`` is the phase-37 precedent), this
|
||||
module pins the JS/CSS markers the story depends on, so a silent
|
||||
regression in the handler, the pinned marker copy, the persistence
|
||||
stamp, or the styling is caught without a browser. The E2E gate is the
|
||||
phase's story suite (task 03).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
SHARED_JS = FRONTEND / "assets" / "shared.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
#: The pinned marker copy (the unit + E2E assertion target): plain
|
||||
#: integers, no thousands separators, the em-dash per the owner's TODO.
|
||||
MARKER_TEMPLATE = '" (truncated — showing "'
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _shared_js() -> str:
|
||||
return SHARED_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tool_result_branch_is_a_first_class_turn_branch() -> None:
|
||||
"""The turn handler must branch on `tool_result` frames (the
|
||||
seventh, optional event type — the A15 extension): the branch sits
|
||||
after `done` (a frame for a settled turn is a tolerated late
|
||||
append, never a crash) and before `error`, settles the tool-line
|
||||
clock like every other frame, and must never flip the state
|
||||
machine (the phase-37/48 lifecycle is untouched)."""
|
||||
js = _js()
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
tool_result_idx = js.find('ev.type === "tool_result"')
|
||||
error_idx = js.find('ev.type === "error"')
|
||||
assert -1 < done_idx < tool_result_idx < error_idx, (
|
||||
"the turn handler must branch on tool_result frames (after done,"
|
||||
" before error)"
|
||||
)
|
||||
branch = js[tool_result_idx:error_idx]
|
||||
assert "settleToolLine()" in branch, (
|
||||
"a frame arrived — the latest tool line's elapsed clock settles"
|
||||
)
|
||||
assert "appendTruncatedNote(wrap, argument, shown, total)" in branch
|
||||
# No UI-state transition: the state machine never knows the marker.
|
||||
assert "setUiState" not in branch
|
||||
assert "aborted" not in branch, (
|
||||
"the aborted guard lives at the dispatch top, not per branch"
|
||||
)
|
||||
|
||||
|
||||
def test_truncation_marker_copy_is_pinned() -> None:
|
||||
"""The marker text is pinned EXACTLY: \" (truncated — showing N of
|
||||
M chars)\" — plain integers (no separators), a leading space (it
|
||||
follows the line's <code> child), the em-dash per the TODO copy.
|
||||
Both app.js's helper and shared.js's local render carry the same
|
||||
template (pixel-identical marker — the phase-50 restore
|
||||
contract)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendTruncatedNote")
|
||||
assert fn != -1, "appendTruncatedNote must exist in app.js"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert MARKER_TEMPLATE in body
|
||||
assert '+ charsShown + " of " + charsTotal + " chars)"' in body
|
||||
shared = _shared_js()
|
||||
fn = shared.find("function addToolLines")
|
||||
assert fn != -1
|
||||
sbody = shared[fn : shared.find("\n}\n", fn)]
|
||||
assert MARKER_TEMPLATE in sbody
|
||||
assert 'chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)"' in sbody
|
||||
|
||||
|
||||
def test_append_truncated_note_matches_newest_line_and_uses_text_content() -> None:
|
||||
"""appendTruncatedNote: the target is the NEWEST `.tool-call` line
|
||||
whose `<code>` child carries the frame's argument (the raw
|
||||
source/path — the same string the matching `tool` frame put in the
|
||||
line), scanned newest-first; the marker is a SPAN sibling appended
|
||||
to the existing line (createElement + className + textContent only
|
||||
— the house "this file never builds HTML" rule, no innerHTML); a
|
||||
frame whose line is gone (New Chat mid-turn) is a silent no-op."""
|
||||
js = _js()
|
||||
fn = js.find("function appendTruncatedNote")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'wrap?.querySelector?.(".tool-calls")' in body, (
|
||||
"a wrap without tool lines is a silent no-op"
|
||||
)
|
||||
assert 'querySelectorAll(".tool-call")' in body
|
||||
assert "for (let i = lines.length - 1; i >= 0; i -= 1)" in body, (
|
||||
"newest line first — the last call for that argument"
|
||||
)
|
||||
assert 'code.textContent !== argument' in body, (
|
||||
"the match key is the code child's argument (the raw source/path)"
|
||||
)
|
||||
assert 'note.className = "truncated-note"' in body
|
||||
assert 'lines[i].appendChild(note)' in body, (
|
||||
"DOM append to the EXISTING line — no new line, no re-render"
|
||||
)
|
||||
assert "innerHTML" not in body, (
|
||||
"no HTML injection surface — createElement + textContent only"
|
||||
)
|
||||
# The no-op guard: a wrap without tool lines returns silently; a scan
|
||||
# that finds no matching line falls off the loop without appending.
|
||||
assert "if (!calls) return;" in body
|
||||
|
||||
|
||||
def test_tool_result_branch_stamps_the_newest_toolacc_entry() -> None:
|
||||
"""The `tool_result` frame stamps the matching toolAcc entry (same
|
||||
argument, NEWEST — the reverse scan mirrors the line match) with
|
||||
`truncated` + `chars_shown` + `chars_total` — the `done` save point
|
||||
below then carries it with zero other change (the persistence
|
||||
shape rides the existing `tools` key). The stamp is gated on the
|
||||
frame's argument + truncated truth (a malformed frame is a silent
|
||||
no-op for persistence)."""
|
||||
js = _js()
|
||||
tool_result_idx = js.find('ev.type === "tool_result"')
|
||||
error_idx = js.find('ev.type === "error"')
|
||||
assert -1 < tool_result_idx < error_idx
|
||||
branch = js[tool_result_idx:error_idx]
|
||||
assert "t.truncated = true" in branch
|
||||
assert "t.chars_shown = shown" in branch
|
||||
assert "t.chars_total = total" in branch
|
||||
assert "for (let i = toolAcc.length - 1; i >= 0; i -= 1)" in branch, (
|
||||
"the NEWEST matching entry (the reverse scan, same rule as the"
|
||||
" line match) gets stamped — then break"
|
||||
)
|
||||
assert "t.argument === argument" in branch
|
||||
assert "break" in branch
|
||||
# The guard: the stamp only runs for a real truncation with an argument.
|
||||
assert "if (argument && ev.truncated)" in branch
|
||||
# The saved payload rides the existing save point — no second tools
|
||||
# key, no new record field.
|
||||
done_block = js[js.find('ev.type === "done"') : tool_result_idx]
|
||||
assert "tools: toolAcc.length ? toolAcc : undefined" in done_block
|
||||
|
||||
|
||||
def test_restore_path_renders_the_stored_marker() -> None:
|
||||
"""The phase-14 LOCAL restore path (renderStoredMessage): a stored
|
||||
tool record with `truncated` + the counts re-renders the SAME
|
||||
marker next to its Reading line, right after the line is restored
|
||||
(the same order as the live frames). A pre-phase-95 record (no
|
||||
field — `t.truncated` falsy) renders unchanged (no marker, no
|
||||
migration)."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1
|
||||
end = js.find("function restoreConversation")
|
||||
body = js[fn:end]
|
||||
assert "appendToolLine(wrap, t.name, arg)" in body
|
||||
assert "t.truncated && arg" in body, (
|
||||
"only an argument-bearing (Reading) record with the flag renders"
|
||||
" the marker"
|
||||
)
|
||||
assert (
|
||||
"appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0,"
|
||||
" Number(t.chars_total) || 0)" in body
|
||||
)
|
||||
# The marker append sits INSIDE the tools loop, after the line append.
|
||||
line_idx = body.find("appendToolLine(wrap, t.name, arg)")
|
||||
note_idx = body.find("appendTruncatedNote(wrap, arg,")
|
||||
assert -1 < line_idx < note_idx
|
||||
|
||||
|
||||
def test_shared_page_renders_the_stored_marker() -> None:
|
||||
"""shared.js's local tool-line render (addToolLines): the same
|
||||
marker from the stored record — a span sibling appended to the
|
||||
line, after the line's existing children (the template text + the
|
||||
<code> argument), textContent only (no HTML from storage, ever). A
|
||||
record saved before phase 95 renders exactly as before."""
|
||||
js = _shared_js()
|
||||
fn = js.find("function addToolLines")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "t.truncated && argument" in body
|
||||
assert 'note.className = "truncated-note"' in body
|
||||
assert 'line.appendChild(note)' in body
|
||||
assert MARKER_TEMPLATE in body
|
||||
# Still textContent-only: the marker adds no innerHTML surface, and
|
||||
# the three argument-bearing lines keep their textContent treatment.
|
||||
assert body.count("code.textContent = argument") == 3
|
||||
assert "innerHTML" not in body
|
||||
|
||||
|
||||
def test_truncated_note_style_is_theme_neutral() -> None:
|
||||
"""styles.css: `.tool-call .truncated-note` exists and colors ONLY
|
||||
through a `var(--…)` token (the phase-92 zero-literal invariant —
|
||||
no new hue; under phase 93's monochrome theme it grays
|
||||
automatically, and the marker stays TEXT, never color alone, B5).
|
||||
--ink-soft is the AA-safe soft-ink the status suffixes already
|
||||
borrow."""
|
||||
css = _css()
|
||||
m = re.search(r"\.tool-call \.truncated-note \{([^}]*)\}", css)
|
||||
assert m, "the .tool-call .truncated-note rule must exist"
|
||||
rule = m.group(1)
|
||||
assert "var(--ink-soft)" in rule
|
||||
assert "color: var(--ink-soft)" in rule
|
||||
# Theme-neutral: the whole rule is a single var() color — no hex,
|
||||
# no rgb(), no other property.
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgb\(", rule)
|
||||
|
||||
|
||||
def test_no_cdn_added() -> None:
|
||||
"""AGENTS.md rule 6: the marker adds no external script/link."""
|
||||
index = (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
assert 'src="http' not in index and 'href="http' not in index
|
||||
+165
-3
@@ -41,7 +41,15 @@ def _source_ref() -> dict:
|
||||
|
||||
|
||||
def _tool_call() -> dict:
|
||||
return {"name": "read", "argument": "Homelab/kubernetes.md"}
|
||||
# Phase 95 (task 02): the truncation record rides the same entry —
|
||||
# the CURRENT full shape (additive fields, defaults for a plain read).
|
||||
return {
|
||||
"name": "read",
|
||||
"argument": "Homelab/kubernetes.md",
|
||||
"truncated": False,
|
||||
"chars_shown": None,
|
||||
"chars_total": None,
|
||||
}
|
||||
|
||||
|
||||
def _user_message(text: str = "How did I install k3s?") -> ChatMessage:
|
||||
@@ -341,8 +349,22 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
|
||||
"suggestions": None,
|
||||
"thinking": "The kubernetes doc covers the cluster layout…",
|
||||
"tools": [
|
||||
{"name": "read", "argument": "Homelab/kubernetes.md"},
|
||||
{"name": "ls", "argument": None},
|
||||
# Phase 95 (task 02): the current full shape — one
|
||||
# truncated read (the marker record) + a plain ls.
|
||||
{
|
||||
"name": "read",
|
||||
"argument": "Homelab/kubernetes.md",
|
||||
"truncated": True,
|
||||
"chars_shown": 128_000,
|
||||
"chars_total": 204_000,
|
||||
},
|
||||
{
|
||||
"name": "ls",
|
||||
"argument": None,
|
||||
"truncated": False,
|
||||
"chars_shown": None,
|
||||
"chars_total": None,
|
||||
},
|
||||
],
|
||||
"stopped": None,
|
||||
},
|
||||
@@ -393,3 +415,143 @@ def test_realistic_payload_round_trips_through_update_model() -> None:
|
||||
}
|
||||
payload = SavedChatUpdate.model_validate({"messages": [msg]})
|
||||
assert payload.model_dump()["messages"] == [msg]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 95 (task 02): ToolCall truncation fields + ChatToolResultEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_call_round_trips_with_truncation_fields() -> None:
|
||||
"""The CURRENT full shape (phase 95 task 02): a truncated read's
|
||||
record (``truncated: True`` + the two non-negative counts) validates
|
||||
and round-trips ``model_dump()`` unchanged — the save payload carries
|
||||
it with zero other change (the UI re-renders the marker from it)."""
|
||||
raw = {
|
||||
"name": "read",
|
||||
"argument": "Homelab/big.md",
|
||||
"truncated": True,
|
||||
"chars_shown": 128_000,
|
||||
"chars_total": 204_000,
|
||||
}
|
||||
call = ToolCall.model_validate(raw)
|
||||
assert call.truncated is True
|
||||
assert call.chars_shown == 128_000
|
||||
assert call.chars_total == 204_000
|
||||
assert call.model_dump() == raw
|
||||
|
||||
|
||||
def test_tool_call_old_shape_validates_with_defaults() -> None:
|
||||
"""Backward-compat (the phase-50 rule): a saved chat written BEFORE
|
||||
phase 95 — tool records without the truncation fields — validates
|
||||
UNCHANGED: ``truncated`` defaults to False (the marker is absent),
|
||||
the counts to None. No migration (``ChatMessage.tools`` is JSON);
|
||||
only the dump gains the additive keys with their defaults."""
|
||||
old_shape = {"name": "read", "argument": "Homelab/kubernetes.md"}
|
||||
call = ToolCall.model_validate(old_shape)
|
||||
assert call.truncated is False
|
||||
assert call.chars_shown is None
|
||||
assert call.chars_total is None
|
||||
dumped = call.model_dump()
|
||||
assert dumped["name"] == "read" and dumped["argument"] == "Homelab/kubernetes.md"
|
||||
assert dumped["truncated"] is False
|
||||
assert dumped["chars_shown"] is None and dumped["chars_total"] is None
|
||||
|
||||
|
||||
def test_tool_call_old_shape_message_still_round_trips_as_record() -> None:
|
||||
"""The record-level backward-compat: a pre-phase-95 brain message
|
||||
(old-shape ``tools``) validates inside ``ChatMessage`` and dumps back
|
||||
as a VALID record of the same shape (the frontend renders it without
|
||||
the marker — ``truncated`` falsy)."""
|
||||
old_message = {
|
||||
"who": "brain",
|
||||
"text": "You've got this!",
|
||||
"sources": None,
|
||||
"deflected": False,
|
||||
"suggestions": None,
|
||||
"thinking": None,
|
||||
"tools": [
|
||||
{"name": "read", "argument": "Homelab/kubernetes.md"},
|
||||
{"name": "read_document", "argument": "Homelab/legacy.md"},
|
||||
],
|
||||
"stopped": None,
|
||||
}
|
||||
msg = ChatMessage.model_validate(old_message)
|
||||
assert all(t.truncated is False for t in (msg.tools or []))
|
||||
# Re-validating the dump is a no-op (lossless record round-trip).
|
||||
ChatMessage.model_validate(msg.model_dump())
|
||||
|
||||
|
||||
def test_tool_call_counts_reject_negative() -> None:
|
||||
"""Phase 83 bounds philosophy: the counts are non-negative
|
||||
(``ge=0``) — a negative count is not a real record."""
|
||||
with pytest.raises(ValidationError):
|
||||
ToolCall.model_validate(
|
||||
{
|
||||
"name": "read",
|
||||
"argument": "x",
|
||||
"truncated": True,
|
||||
"chars_shown": -1,
|
||||
"chars_total": 5,
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
ToolCall.model_validate(
|
||||
{
|
||||
"name": "read",
|
||||
"argument": "x",
|
||||
"truncated": True,
|
||||
"chars_shown": 5,
|
||||
"chars_total": -1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_chat_tool_result_event_shape() -> None:
|
||||
"""The A15 extension's wire shape (phase 95 task 02): the seventh,
|
||||
OPTIONAL SSE event type — ``{type, name, argument, truncated,
|
||||
chars_shown, chars_total}`` — with the pinned field order, the
|
||||
``tool_result`` default, ``truncated`` defaulting True (the emission
|
||||
trigger), and the non-negative counts."""
|
||||
from app.schemas import ChatToolResultEvent
|
||||
|
||||
ev = ChatToolResultEvent(
|
||||
name="read",
|
||||
argument="docs/big.md",
|
||||
truncated=True,
|
||||
chars_shown=128_000,
|
||||
chars_total=204_000,
|
||||
)
|
||||
dumped = ev.model_dump()
|
||||
assert list(dumped) == [
|
||||
"type",
|
||||
"name",
|
||||
"argument",
|
||||
"truncated",
|
||||
"chars_shown",
|
||||
"chars_total",
|
||||
]
|
||||
assert dumped == {
|
||||
"type": "tool_result",
|
||||
"name": "read",
|
||||
"argument": "docs/big.md",
|
||||
"truncated": True,
|
||||
"chars_shown": 128_000,
|
||||
"chars_total": 204_000,
|
||||
}
|
||||
# The emission trigger defaults: the pump always builds the frame from
|
||||
# a piece, so a frame that ever exists carries the truncation truth.
|
||||
minimal = ChatToolResultEvent(
|
||||
name="read", argument=None, chars_shown=0, chars_total=0
|
||||
).model_dump()
|
||||
assert minimal["truncated"] is True
|
||||
|
||||
|
||||
def test_chat_tool_result_event_counts_reject_negative() -> None:
|
||||
"""The frame's counts are non-negative (``ge=0``), like the record's."""
|
||||
from app.schemas import ChatToolResultEvent
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ChatToolResultEvent(name="read", argument="x", chars_shown=-1, chars_total=5)
|
||||
with pytest.raises(ValidationError):
|
||||
ChatToolResultEvent(name="read", argument="x", chars_shown=5, chars_total=-1)
|
||||
|
||||
Reference in New Issue
Block a user